From 61cad5dd96ffe274d1e9f31d1f76946eca3f20cf Mon Sep 17 00:00:00 2001 From: reya <123083837+reyamir@users.noreply.github.com> Date: Thu, 25 Sep 2025 08:03:14 +0700 Subject: [PATCH] chore: refactor the input component (#165) * refactor the input component * fix clippy * clean up --- Cargo.lock | 74 +- crates/coop/src/views/chat/mod.rs | 15 +- crates/coop/src/views/compose.rs | 2 +- crates/coop/src/views/preferences.rs | 2 +- crates/coop/src/views/sidebar/mod.rs | 7 +- crates/ui/Cargo.toml | 3 + crates/ui/src/history.rs | 2 +- crates/ui/src/input/blink_cursor.rs | 15 +- crates/ui/src/input/change.rs | 14 +- crates/ui/src/input/clear_button.rs | 9 +- crates/ui/src/input/cursor.rs | 46 + crates/ui/src/input/element.rs | 629 ++++++---- crates/ui/src/input/mask_pattern.rs | 788 ++++++------ crates/ui/src/input/mod.rs | 4 +- crates/ui/src/input/mode.rs | 129 ++ crates/ui/src/input/rope_ext.rs | 207 ++++ crates/ui/src/input/state.rs | 1662 +++++++++++++++----------- crates/ui/src/input/text_input.rs | 192 +-- crates/ui/src/input/text_wrapper.rs | 314 +++-- crates/ui/src/list/list.rs | 8 +- 20 files changed, 2529 insertions(+), 1593 deletions(-) create mode 100644 crates/ui/src/input/cursor.rs create mode 100644 crates/ui/src/input/mode.rs create mode 100644 crates/ui/src/input/rope_ext.rs diff --git a/Cargo.lock b/Cargo.lock index 2df93fbb..5ee8da30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -541,7 +541,7 @@ dependencies = [ "bitflags 2.9.4", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools 0.11.0", "log", "prettyplease", "proc-macro2", @@ -561,7 +561,7 @@ dependencies = [ "bitflags 2.9.4", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools 0.11.0", "log", "prettyplease", "proc-macro2", @@ -1128,7 +1128,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#681a4adc42df0c93b95fd1a36a3ad47eaca086c0" +source = "git+https://github.com/zed-industries/zed#5612a961b02522a5e3f6292873fec1f853643734" dependencies = [ "indexmap", "rustc-hash 2.1.1", @@ -1569,7 +1569,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#681a4adc42df0c93b95fd1a36a3ad47eaca086c0" +source = "git+https://github.com/zed-industries/zed#5612a961b02522a5e3f6292873fec1f853643734" dependencies = [ "proc-macro2", "quote", @@ -2015,6 +2015,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "flume" version = "0.11.1" @@ -2496,7 +2505,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#681a4adc42df0c93b95fd1a36a3ad47eaca086c0" +source = "git+https://github.com/zed-industries/zed#5612a961b02522a5e3f6292873fec1f853643734" dependencies = [ "anyhow", "as-raw-xcb-connection", @@ -2590,7 +2599,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#681a4adc42df0c93b95fd1a36a3ad47eaca086c0" +source = "git+https://github.com/zed-industries/zed#5612a961b02522a5e3f6292873fec1f853643734" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -2602,7 +2611,7 @@ dependencies = [ [[package]] name = "gpui_tokio" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#681a4adc42df0c93b95fd1a36a3ad47eaca086c0" +source = "git+https://github.com/zed-industries/zed#5612a961b02522a5e3f6292873fec1f853643734" dependencies = [ "anyhow", "gpui", @@ -2822,7 +2831,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#681a4adc42df0c93b95fd1a36a3ad47eaca086c0" +source = "git+https://github.com/zed-industries/zed#5612a961b02522a5e3f6292873fec1f853643734" dependencies = [ "anyhow", "bytes", @@ -2842,7 +2851,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#681a4adc42df0c93b95fd1a36a3ad47eaca086c0" +source = "git+https://github.com/zed-industries/zed#5612a961b02522a5e3f6292873fec1f853643734" dependencies = [ "rustls", "rustls-platform-verifier", @@ -3544,6 +3553,19 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri", + "serde", + "serde_json", + "serde_repr", +] + [[package]] name = "lyon" version = "1.0.16" @@ -3634,7 +3656,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#681a4adc42df0c93b95fd1a36a3ad47eaca086c0" +source = "git+https://github.com/zed-industries/zed#5612a961b02522a5e3f6292873fec1f853643734" dependencies = [ "anyhow", "bindgen 0.71.1", @@ -4477,7 +4499,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#681a4adc42df0c93b95fd1a36a3ad47eaca086c0" +source = "git+https://github.com/zed-industries/zed#5612a961b02522a5e3f6292873fec1f853643734" dependencies = [ "collections", "serde", @@ -5085,7 +5107,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#681a4adc42df0c93b95fd1a36a3ad47eaca086c0" +source = "git+https://github.com/zed-industries/zed#5612a961b02522a5e3f6292873fec1f853643734" dependencies = [ "derive_refineable", "workspace-hack", @@ -5239,7 +5261,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#681a4adc42df0c93b95fd1a36a3ad47eaca086c0" +source = "git+https://github.com/zed-industries/zed#5612a961b02522a5e3f6292873fec1f853643734" dependencies = [ "anyhow", "bytes", @@ -5291,6 +5313,21 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rope" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#5612a961b02522a5e3f6292873fec1f853643734" +dependencies = [ + "arrayvec", + "log", + "rayon", + "smallvec", + "sum_tree", + "unicode-segmentation", + "util", + "workspace-hack", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -5774,7 +5811,7 @@ checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749" [[package]] name = "semantic_version" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#681a4adc42df0c93b95fd1a36a3ad47eaca086c0" +source = "git+https://github.com/zed-industries/zed#5612a961b02522a5e3f6292873fec1f853643734" dependencies = [ "anyhow", "serde", @@ -6226,7 +6263,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#681a4adc42df0c93b95fd1a36a3ad47eaca086c0" +source = "git+https://github.com/zed-industries/zed#5612a961b02522a5e3f6292873fec1f853643734" dependencies = [ "arrayvec", "log", @@ -7086,15 +7123,18 @@ dependencies = [ "itertools 0.13.0", "linkify", "log", + "lsp-types", "nostr-sdk", "once_cell", "regex", "registry", + "rope", "rust-i18n", "serde", "serde_json", "smallvec", "smol", + "sum_tree", "theme", "unicode-segmentation", "uuid", @@ -7269,7 +7309,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#681a4adc42df0c93b95fd1a36a3ad47eaca086c0" +source = "git+https://github.com/zed-industries/zed#5612a961b02522a5e3f6292873fec1f853643734" dependencies = [ "anyhow", "async-fs", @@ -7304,7 +7344,7 @@ dependencies = [ [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#681a4adc42df0c93b95fd1a36a3ad47eaca086c0" +source = "git+https://github.com/zed-industries/zed#5612a961b02522a5e3f6292873fec1f853643734" dependencies = [ "perf", "quote", diff --git a/crates/coop/src/views/chat/mod.rs b/crates/coop/src/views/chat/mod.rs index 334fa661..a879a0c1 100644 --- a/crates/coop/src/views/chat/mod.rs +++ b/crates/coop/src/views/chat/mod.rs @@ -89,11 +89,8 @@ impl Chat { let input = cx.new(|cx| { InputState::new(window, cx) .placeholder(t!("chat.placeholder")) - .multi_line() - .prevent_new_line_on_enter() - .rows(1) - .multi_line() .auto_grow(1, 20) + .prevent_new_line_on_enter() .clean_on_escape() }); @@ -155,14 +152,8 @@ impl Chat { &input, window, move |this: &mut Self, _input, event, window, cx| { - match event { - InputEvent::PressEnter { .. } => { - this.send_message(window, cx); - } - InputEvent::Change(_) => { - // this.mention_popup(text, input, cx); - } - _ => {} + if let InputEvent::PressEnter { .. } = event { + this.send_message(window, cx); }; }, ), diff --git a/crates/coop/src/views/compose.rs b/crates/coop/src/views/compose.rs index c8d243ea..3074af08 100644 --- a/crates/coop/src/views/compose.rs +++ b/crates/coop/src/views/compose.rs @@ -424,7 +424,7 @@ impl Compose { impl Render for Compose { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let error = self.error_message.read(cx).as_ref(); - let loading = self.user_input.read(cx).loading(cx); + let loading = self.user_input.read(cx).loading; let contacts = self.contacts.read(cx); v_flex() diff --git a/crates/coop/src/views/preferences.rs b/crates/coop/src/views/preferences.rs index aaa9812e..62f36763 100644 --- a/crates/coop/src/views/preferences.rs +++ b/crates/coop/src/views/preferences.rs @@ -204,7 +204,7 @@ impl Render for Preferences { .on_click(move |_, _window, cx| { if let Some(input) = input_state.upgrade() { let Ok(url) = - Url::parse(input.read(cx).value()) + Url::parse(&input.read(cx).value()) else { return; }; diff --git a/crates/coop/src/views/sidebar/mod.rs b/crates/coop/src/views/sidebar/mod.rs index 03f99ea2..f9a7f3a7 100644 --- a/crates/coop/src/views/sidebar/mod.rs +++ b/crates/coop/src/views/sidebar/mod.rs @@ -100,12 +100,12 @@ impl Sidebar { subscriptions.push( // Subscribe for find input events - cx.subscribe_in(&find_input, window, |this, _state, event, window, cx| { + cx.subscribe_in(&find_input, window, |this, state, event, window, cx| { match event { InputEvent::PressEnter { .. } => this.search(window, cx), - InputEvent::Change(text) => { + InputEvent::Change => { // Clear the result when input is empty - if text.is_empty() { + if state.read(cx).value().is_empty() { this.clear_search_results(window, cx); } else { // Run debounced search @@ -722,6 +722,7 @@ impl Render for Sidebar { .small() .cleanable() .appearance(true) + .text_xs() .suffix( Button::new("find") .icon(IconName::Search) diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 8801ec74..2d2ee291 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -28,3 +28,6 @@ uuid = "1.10" once_cell = "1.19.0" image = "0.25.1" linkify = "0.10.0" +lsp-types = "0.97.0" +rope = { git = "https://github.com/zed-industries/zed.git" } +sum_tree = { git = "https://github.com/zed-industries/zed.git" } diff --git a/crates/ui/src/history.rs b/crates/ui/src/history.rs index cb39a2e5..348507b8 100644 --- a/crates/ui/src/history.rs +++ b/crates/ui/src/history.rs @@ -22,10 +22,10 @@ pub struct History { redos: Vec, last_changed_at: Instant, version: usize, - pub(crate) ignore: bool, max_undo: usize, group_interval: Option, unique: bool, + pub ignore: bool, } impl History diff --git a/crates/ui/src/input/blink_cursor.rs b/crates/ui/src/input/blink_cursor.rs index a33e0e16..ac3f57a5 100644 --- a/crates/ui/src/input/blink_cursor.rs +++ b/crates/ui/src/input/blink_cursor.rs @@ -1,9 +1,10 @@ use std::time::Duration; -use gpui::{Context, Timer}; +use gpui::{px, Context, Pixels, Timer}; static INTERVAL: Duration = Duration::from_millis(500); static PAUSE_DELAY: Duration = Duration::from_millis(300); +pub(super) const CURSOR_WIDTH: Pixels = px(1.5); /// To manage the Input cursor blinking. /// @@ -11,7 +12,7 @@ static PAUSE_DELAY: Duration = Duration::from_millis(300); /// Every loop will notify the view to update the `visible`, and Input will observe this update to touch repaint. /// /// The input painter will check if this in visible state, then it will draw the cursor. -pub(crate) struct BlinkCursor { +pub struct BlinkCursor { visible: bool, paused: bool, epoch: usize, @@ -52,10 +53,8 @@ impl BlinkCursor { // Schedule the next blink let epoch = self.next_epoch(); - cx.spawn(async move |this, cx| { Timer::after(INTERVAL).await; - if let Some(this) = this.upgrade() { this.update(cx, |this, cx| this.blink(epoch, cx)).ok(); } @@ -71,11 +70,11 @@ impl BlinkCursor { /// Pause the blinking, and delay 500ms to resume the blinking. pub fn pause(&mut self, cx: &mut Context) { self.paused = true; + self.visible = true; cx.notify(); // delay 500ms to start the blinking let epoch = self.next_epoch(); - cx.spawn(async move |this, cx| { Timer::after(PAUSE_DELAY).await; @@ -90,3 +89,9 @@ impl BlinkCursor { .detach(); } } + +impl Default for BlinkCursor { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/ui/src/input/change.rs b/crates/ui/src/input/change.rs index 0ad23b15..67793398 100644 --- a/crates/ui/src/input/change.rs +++ b/crates/ui/src/input/change.rs @@ -1,28 +1,28 @@ use std::fmt::Debug; -use std::ops::Range; use crate::history::HistoryItem; +use crate::input::cursor::Selection; #[derive(Debug, PartialEq, Clone)] pub struct Change { - pub(crate) old_range: Range, + pub(crate) old_range: Selection, pub(crate) old_text: String, - pub(crate) new_range: Range, + pub(crate) new_range: Selection, pub(crate) new_text: String, version: usize, } impl Change { pub fn new( - old_range: Range, + old_range: impl Into, old_text: &str, - new_range: Range, + new_range: impl Into, new_text: &str, ) -> Self { Self { - old_range, + old_range: old_range.into(), old_text: old_text.to_string(), - new_range, + new_range: new_range.into(), new_text: new_text.to_string(), version: 0, } diff --git a/crates/ui/src/input/clear_button.rs b/crates/ui/src/input/clear_button.rs index 7874a82b..dfe170fd 100644 --- a/crates/ui/src/input/clear_button.rs +++ b/crates/ui/src/input/clear_button.rs @@ -1,16 +1,15 @@ use gpui::{App, Styled}; -use i18n::t; use theme::ActiveTheme; -use crate::button::{Button, ButtonVariants as _}; -use crate::{Icon, IconName, Sizable as _}; +use crate::button::{Button, ButtonVariants}; +use crate::{Icon, IconName, Sizable}; #[inline] pub(crate) fn clear_button(cx: &App) -> Button { Button::new("clean") .icon(Icon::new(IconName::CloseCircle)) - .tooltip(t!("common.clear")) + .tooltip("Clear") .small() - .text_color(cx.theme().text_muted) .transparent() + .text_color(cx.theme().text_muted) } diff --git a/crates/ui/src/input/cursor.rs b/crates/ui/src/input/cursor.rs new file mode 100644 index 00000000..6a915623 --- /dev/null +++ b/crates/ui/src/input/cursor.rs @@ -0,0 +1,46 @@ +use std::ops::Range; + +/// A selection in the text, represented by start and end byte indices. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)] +pub struct Selection { + pub start: usize, + pub end: usize, +} + +impl Selection { + pub fn new(start: usize, end: usize) -> Self { + Self { start, end } + } + + pub fn len(&self) -> usize { + self.end.saturating_sub(self.start) + } + + pub fn is_empty(&self) -> bool { + self.start == self.end + } + + /// Clears the selection, setting start and end to 0. + pub fn clear(&mut self) { + self.start = 0; + self.end = 0; + } + + /// Checks if the given offset is within the selection range. + pub fn contains(&self, offset: usize) -> bool { + offset >= self.start && offset < self.end + } +} + +impl From> for Selection { + fn from(value: Range) -> Self { + Self::new(value.start, value.end) + } +} +impl From for Range { + fn from(value: Selection) -> Self { + value.start..value.end + } +} + +pub type Position = lsp_types::Position; diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs index 1928d460..2cdf8ea6 100644 --- a/crates/ui/src/input/element.rs +++ b/crates/ui/src/input/element.rs @@ -1,29 +1,34 @@ -use std::{ops::Range, rc::Rc}; +use std::ops::Range; +use std::rc::Rc; use gpui::{ fill, point, px, relative, size, App, Bounds, Corners, Element, ElementId, ElementInputHandler, - Entity, GlobalElementId, IntoElement, LayoutId, MouseButton, MouseMoveEvent, Path, Pixels, - Point, SharedString, Size, Style, TextAlign, TextRun, UnderlineStyle, Window, WrappedLine, + Entity, GlobalElementId, Half, Hitbox, IntoElement, LayoutId, MouseButton, MouseMoveEvent, + Path, Pixels, Point, ShapedLine, SharedString, Size, Style, TextAlign, TextRun, UnderlineStyle, + Window, }; +use rope::Rope; use smallvec::SmallVec; use theme::ActiveTheme; -use super::{InputState, LastLayout}; +use super::blink_cursor::CURSOR_WIDTH; +use super::rope_ext::RopeExt; +use super::state::{InputState, LastLayout}; use crate::Root; -const CURSOR_THICKNESS: Pixels = px(2.); -const RIGHT_MARGIN: Pixels = px(5.); -const BOTTOM_MARGIN_ROWS: usize = 1; +const BOTTOM_MARGIN_ROWS: usize = 3; +pub(super) const RIGHT_MARGIN: Pixels = px(10.); +pub(super) const LINE_NUMBER_RIGHT_MARGIN: Pixels = px(10.); pub(super) struct TextElement { - input: Entity, + pub(crate) state: Entity, placeholder: SharedString, } impl TextElement { - pub(super) fn new(input: Entity) -> Self { + pub(super) fn new(state: Entity) -> Self { Self { - input, + state, placeholder: SharedString::default(), } } @@ -36,12 +41,12 @@ impl TextElement { fn paint_mouse_listeners(&mut self, window: &mut Window, _: &mut App) { window.on_mouse_event({ - let input = self.input.clone(); + let state = self.state.clone(); move |event: &MouseMoveEvent, _, window, cx| { if event.pressed_button == Some(MouseButton::Left) { - input.update(cx, |input, cx| { - input.on_drag_move(event, window, cx); + state.update(cx, |state, cx| { + state.on_drag_move(event, window, cx); }); } } @@ -52,33 +57,44 @@ impl TextElement { /// /// - cursor bounds /// - scroll offset - /// - current line index + /// - current row index (No only the visible lines, but all lines) + /// + /// This method also will update for track scroll to cursor. fn layout_cursor( &self, - lines: &[WrappedLine], - line_height: Pixels, + last_layout: &LastLayout, bounds: &mut Bounds, - line_number_width: Pixels, - window: &mut Window, + _: &mut Window, cx: &mut App, ) -> (Option>, Point, Option) { - let input = self.input.read(cx); - let mut selected_range = input.selected_range.clone(); - if let Some(marked_range) = &input.marked_range { - selected_range = marked_range.end..marked_range.end; + let state = self.state.read(cx); + + let line_height = last_layout.line_height; + let visible_range = &last_layout.visible_range; + let lines = &last_layout.lines; + let text_wrapper = &state.text_wrapper; + let line_number_width = last_layout.line_number_width; + + let mut selected_range = state.selected_range; + if let Some(ime_marked_range) = &state.ime_marked_range { + selected_range = (ime_marked_range.end..ime_marked_range.end).into(); } - let cursor_offset = input.cursor_offset(); - let mut current_line_index = None; - let mut scroll_offset = input.scroll_handle.offset(); + let cursor = state.cursor(); + let mut current_row = None; + let mut scroll_offset = state.scroll_handle.offset(); let mut cursor_bounds = 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 + let top_bottom_margin = if state.mode.is_auto_grow() { + #[allow(clippy::if_same_then_else)] + line_height + } else if visible_range.len() < BOTTOM_MARGIN_ROWS * 8 { + line_height } else { - BOTTOM_MARGIN_ROWS * line_height + line_height + BOTTOM_MARGIN_ROWS * 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; @@ -86,68 +102,98 @@ impl TextElement { let mut prev_lines_offset = 0; let mut offset_y = px(0.); - for (line_ix, line) in lines.iter().enumerate() { + + for (ix, wrap_line) in text_wrapper.lines.iter().enumerate() { + let row = ix; + let line_origin = point(px(0.), offset_y); + // break loop if all cursor positions are found if cursor_pos.is_some() && cursor_start.is_some() && cursor_end.is_some() { break; } - let line_origin = point(px(0.), offset_y); - if cursor_pos.is_none() { - let offset = cursor_offset.saturating_sub(prev_lines_offset); - if let Some(pos) = line.position_for_index(offset, line_height) { - current_line_index = Some(line_ix); - cursor_pos = Some(line_origin + pos); + let in_visible_range = ix >= visible_range.start; + if let Some(line) = in_visible_range + .then(|| lines.get(ix.saturating_sub(visible_range.start))) + .flatten() + { + // If in visible range lines + if cursor_pos.is_none() { + let offset = cursor.saturating_sub(prev_lines_offset); + if let Some(pos) = line.position_for_index(offset, line_height) { + current_row = Some(row); + cursor_pos = Some(line_origin + pos); + } } - } - if cursor_start.is_none() { - let offset = selected_range.start.saturating_sub(prev_lines_offset); - if let Some(pos) = line.position_for_index(offset, line_height) { - cursor_start = Some(line_origin + pos); + if cursor_start.is_none() { + let offset = selected_range.start.saturating_sub(prev_lines_offset); + if let Some(pos) = line.position_for_index(offset, line_height) { + cursor_start = Some(line_origin + pos); + } } - } - if cursor_end.is_none() { - let offset = selected_range.end.saturating_sub(prev_lines_offset); - if let Some(pos) = line.position_for_index(offset, line_height) { - cursor_end = Some(line_origin + pos); + if cursor_end.is_none() { + let offset = selected_range.end.saturating_sub(prev_lines_offset); + if let Some(pos) = line.position_for_index(offset, line_height) { + cursor_end = Some(line_origin + pos); + } } - } - offset_y += line.size(line_height).height; - // +1 for skip the last `\n` - prev_lines_offset += line.len() + 1; + offset_y += line.size(line_height).height; + // +1 for the last `\n` + prev_lines_offset += line.len() + 1; + } else { + // If not in the visible range. + + // Just increase the offset_y and prev_lines_offset. + // This will let the scroll_offset to track the cursor position correctly. + if prev_lines_offset >= cursor && cursor_pos.is_none() { + current_row = Some(row); + cursor_pos = Some(line_origin); + } + if prev_lines_offset >= selected_range.start && cursor_start.is_none() { + cursor_start = Some(line_origin); + } + if prev_lines_offset >= selected_range.end && cursor_end.is_none() { + cursor_end = Some(line_origin); + } + + offset_y += wrap_line.height(line_height); + // +1 for the last `\n` + prev_lines_offset += wrap_line.len() + 1; + } } if let (Some(cursor_pos), Some(cursor_start), Some(cursor_end)) = (cursor_pos, cursor_start, cursor_end) { - let cursor_moved = input.last_cursor_offset != Some(cursor_offset); - let selection_changed = input.last_selected_range != Some(selected_range.clone()); - - if cursor_moved || selection_changed { - scroll_offset.x = - if scroll_offset.x + cursor_pos.x > (bounds.size.width - RIGHT_MARGIN) { - // cursor is out of right - bounds.size.width - RIGHT_MARGIN - cursor_pos.x - } else if scroll_offset.x + cursor_pos.x < px(0.) { - // cursor is out of left - scroll_offset.x - cursor_pos.x - } else { - scroll_offset.x - }; - scroll_offset.y = if scroll_offset.y + cursor_pos.y + line_height - > bounds.size.height - bottom_margin + let selection_changed = state.last_selected_range != Some(selected_range); + if selection_changed { + scroll_offset.x = if scroll_offset.x + cursor_pos.x + > (bounds.size.width - line_number_width - RIGHT_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 + // cursor is out of right + bounds.size.width - line_number_width - RIGHT_MARGIN - cursor_pos.x + } else if scroll_offset.x + cursor_pos.x < px(0.) { + // cursor is out of left + scroll_offset.x - cursor_pos.x } else { - scroll_offset.y + scroll_offset.x }; - if input.selection_reversed { + // If we change the scroll_offset.y, GPUI will render and trigger the next run loop. + // So, here we just adjust offset by `line_height` for move smooth. + scroll_offset.y = + if scroll_offset.y + cursor_pos.y > bounds.size.height - top_bottom_margin { + // cursor is out of bottom + scroll_offset.y - line_height + } else if scroll_offset.y + cursor_pos.y < top_bottom_margin { + // cursor is out of top + (scroll_offset.y + line_height).min(px(0.)) + } else { + scroll_offset.y + }; + + if state.selection_reversed { if scroll_offset.x + cursor_start.x < px(0.) { // selection start is out of left scroll_offset.x = -cursor_start.x; @@ -168,54 +214,55 @@ impl TextElement { } } - if input.show_cursor(window, cx) { - // cursor blink - let cursor_height = line_height; - cursor_bounds = Some(Bounds::new( - point( - bounds.left() + cursor_pos.x + line_number_width + scroll_offset.x, - bounds.top() + cursor_pos.y + ((line_height - cursor_height) / 2.), - ), - size(CURSOR_THICKNESS, cursor_height), - )); - }; + // cursor bounds + let cursor_height = line_height; + cursor_bounds = Some(Bounds::new( + point( + bounds.left() + cursor_pos.x + line_number_width + scroll_offset.x, + bounds.top() + cursor_pos.y + ((line_height - cursor_height) / 2.), + ), + size(CURSOR_WIDTH, cursor_height), + )); + } + + if let Some(deferred_scroll_offset) = state.deferred_scroll_offset { + scroll_offset = deferred_scroll_offset; } bounds.origin += scroll_offset; - (cursor_bounds, scroll_offset, current_line_index) + (cursor_bounds, scroll_offset, current_row) } - fn layout_selections( - &self, - lines: &[WrappedLine], - line_height: Pixels, + /// Layout the match range to a Path. + pub(crate) fn layout_match_range( + range: Range, + last_layout: &LastLayout, bounds: &mut Bounds, - line_number_width: Pixels, - _: &mut Window, - cx: &mut App, ) -> Option> { - let input = self.input.read(cx); - let mut selected_range = input.selected_range.clone(); - if let Some(marked_range) = &input.marked_range { - if !marked_range.is_empty() { - selected_range = marked_range.end..marked_range.end; - } - } - if selected_range.is_empty() { + if range.is_empty() { return None; } - let (start_ix, end_ix) = if selected_range.start < selected_range.end { - (selected_range.start, selected_range.end) - } else { - (selected_range.end, selected_range.start) - }; + if range.start < last_layout.visible_range_offset.start + || range.end > last_layout.visible_range_offset.end + { + return None; + } - let mut prev_lines_offset = 0; + let line_height = last_layout.line_height; + let visible_top = last_layout.visible_top; + let visible_start_offset = last_layout.visible_range_offset.start; + let lines = &last_layout.lines; + let line_number_width = last_layout.line_number_width; + + let start_ix = range.start; + let end_ix = range.end; + + let mut prev_lines_offset = visible_start_offset; + let mut offset_y = visible_top; let mut line_corners = vec![]; - let mut offset_y = px(0.); for line in lines.iter() { let line_size = line.size(line_height); let line_wrap_width = line_size.width; @@ -239,7 +286,6 @@ 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; } @@ -322,39 +368,79 @@ impl TextElement { builder.build().ok() } + fn layout_selections( + &self, + last_layout: &LastLayout, + bounds: &mut Bounds, + cx: &mut App, + ) -> Option> { + let state = self.state.read(cx); + let mut selected_range = state.selected_range; + if let Some(ime_marked_range) = &state.ime_marked_range { + if !ime_marked_range.is_empty() { + selected_range = (ime_marked_range.end..ime_marked_range.end).into(); + } + } + if selected_range.is_empty() { + return None; + } + + let (start_ix, end_ix) = if selected_range.start < selected_range.end { + (selected_range.start, selected_range.end) + } else { + (selected_range.end, selected_range.start) + }; + + let range = start_ix.max(last_layout.visible_range_offset.start) + ..end_ix.min(last_layout.visible_range_offset.end); + + Self::layout_match_range(range, last_layout, bounds) + } + /// Calculate the visible range of lines in the viewport. /// - /// The visible range is based on unwrapped lines (Zero based). + /// Returns + /// + /// - visible_range: The visible range is based on unwrapped lines (Zero based). + /// - visible_top: The top position of the first visible line in the scroll viewport. fn calculate_visible_range( &self, state: &InputState, line_height: Pixels, input_height: Pixels, - ) -> Range { - if state.is_single_line() { - return 0..1; + ) -> (Range, Pixels) { + // Add extra rows to avoid showing empty space when scroll to bottom. + let extra_rows = 1; + let mut visible_top = px(0.); + if state.mode.is_single_line() { + return (0..1, visible_top); } - let scroll_top = -state.scroll_handle.offset().y; - let total_lines = state.text_wrapper.lines.len(); + let total_lines = state.text_wrapper.len(); + 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_top = px(0.); - + let mut line_bottom = px(0.); for (ix, line) in state.text_wrapper.lines.iter().enumerate() { - line_top += line.height(line_height); + let wrapped_height = line.height(line_height); + line_bottom += wrapped_height; - if line_top < scroll_top { + if line_bottom < -scroll_top { + visible_top = line_bottom - wrapped_height; visible_range.start = ix; } - if line_top > scroll_top + input_height { - visible_range.end = (ix + 1).min(total_lines); + if line_bottom + scroll_top >= input_height { + visible_range.end = (ix + extra_rows).min(total_lines); break; } } - visible_range + (visible_range, visible_top) } } @@ -362,13 +448,17 @@ pub(super) struct PrepaintState { /// The lines of entire lines. last_layout: LastLayout, /// The lines only contains the visible lines in the viewport, based on `visible_range`. - line_numbers: Option>>, - line_number_width: Pixels, + /// + /// The child is the soft lines. + line_numbers: Option>>, /// Size of the scrollable area by entire lines. scroll_size: Size, cursor_bounds: Option>, cursor_scroll_offset: Point, selection_path: Option>, + hover_highlight_path: Option>, + search_match_paths: Vec<(Path, bool)>, + hover_definition_hitbox: Option, bounds: Bounds, } @@ -380,34 +470,9 @@ impl IntoElement for TextElement { } } -/// A debug function to print points as SVG path. -#[allow(unused)] -fn print_points_as_svg_path(line_corners: &Vec>>, points: &[Point]) { - 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; + type RequestLayoutState = (); fn id(&self) -> Option { None @@ -424,19 +489,20 @@ impl Element for TextElement { window: &mut Window, cx: &mut App, ) -> (LayoutId, Self::RequestLayoutState) { - let input = self.input.read(cx); + let state = self.state.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() { + if state.mode.is_multi_line() { style.flex_grow = 1.0; - if let Some(h) = input.mode.height() { - style.size.height = h.into(); - style.min_size.height = line_height.into(); + style.size.height = relative(1.).into(); + if state.mode.is_auto_grow() { + // Auto grow to let height match to rows, but not exceed max rows. + let rows = state.mode.max_rows().min(state.mode.rows()); + style.min_size.height = (rows * line_height).into(); } else { - style.size.height = relative(1.).into(); - style.min_size.height = (input.mode.rows() * line_height).into(); + style.min_size.height = line_height.into(); } } else { // For single-line inputs, the minimum height should be the line height @@ -455,11 +521,19 @@ impl Element for TextElement { window: &mut Window, cx: &mut App, ) -> Self::PrepaintState { + let state = self.state.read(cx); let line_height = window.line_height(); - let input = self.input.read(cx); - let multi_line = input.is_multi_line(); - let visible_range = self.calculate_visible_range(input, line_height, bounds.size.height); - let text = input.text.clone(); + + let (visible_range, visible_top) = + self.calculate_visible_range(state, line_height, bounds.size.height); + let visible_start_offset = state.text.line_start_offset(visible_range.start); + let visible_end_offset = state + .text + .line_end_offset(visible_range.end.saturating_sub(1)); + + let state = self.state.read(cx); + let multi_line = state.mode.is_multi_line(); + let text = state.text.clone(); let is_empty = text.is_empty(); let placeholder = self.placeholder.clone(); let style = window.text_style(); @@ -467,9 +541,9 @@ impl Element for TextElement { let mut bounds = bounds; let (display_text, text_color) = if is_empty { - (placeholder, cx.theme().text_muted) - } else if input.masked { - ("*".repeat(text.chars().count()).into(), cx.theme().text) + (Rope::from(placeholder.as_str()), cx.theme().text_muted) + } else if state.masked { + (Rope::from("*".repeat(text.chars_count())), cx.theme().text) } else { (text.clone(), cx.theme().text) }; @@ -500,20 +574,20 @@ impl Element for TextElement { let runs = if !is_empty { vec![run] - } else if let Some(marked_range) = &input.marked_range { + } else if let Some(ime_marked_range) = &state.ime_marked_range { // IME marked text vec![ TextRun { - len: marked_range.start, + len: ime_marked_range.start, ..run.clone() }, TextRun { - len: marked_range.end - marked_range.start, + len: ime_marked_range.end - ime_marked_range.start, underline: marked_run.underline, ..run.clone() }, TextRun { - len: display_text.len() - marked_range.end, + len: display_text.len() - ime_marked_range.end, ..run.clone() }, ] @@ -524,35 +598,76 @@ impl Element for TextElement { vec![run] }; - let wrap_width = if multi_line { + let wrap_width = if multi_line && state.soft_wrap { Some(bounds.size.width - line_number_width - RIGHT_MARGIN) } else { None }; + // NOTE: Here 50 lines about 150µs + // let measure = crate::Measure::new("shape_text"); + let visible_text = display_text + .slice_rows(visible_range.start as u32..visible_range.end as u32) + .to_string(); + let lines = window .text_system() - .shape_text(display_text, font_size, &runs, wrap_width, None) + .shape_text(visible_text.into(), font_size, &runs, wrap_width, None) .expect("failed to shape text"); + // measure.end(); - let total_wrapped_lines = lines - .iter() - .map(|line| { - // +1 is the first line, `wrap_boundaries` is the wrapped lines after the `\n`. - 1 + line.wrap_boundaries.len() - }) - .sum::(); + let mut longest_line_width = wrap_width.unwrap_or(px(0.)); + if state.mode.is_multi_line() && !state.soft_wrap && lines.len() > 1 { + let longtest_line: SharedString = state + .text + .line(state.text.summary().longest_row as usize) + .to_string() + .into(); + longest_line_width = window + .text_system() + .shape_line( + longtest_line.clone(), + font_size, + &[TextRun { + len: longtest_line.len(), + font: style.font(), + color: gpui::black(), + background_color: None, + underline: None, + strikethrough: None, + }], + wrap_width, + ) + .width; + } - let max_line_width = lines - .iter() - .map(|line| line.width()) - .max() - .unwrap_or(bounds.size.width); + let total_wrapped_lines = state.text_wrapper.len(); + let empty_bottom_height = bounds + .size + .height + .half() + .max(BOTTOM_MARGIN_ROWS * line_height); let scroll_size = size( - max_line_width + line_number_width + RIGHT_MARGIN, - (total_wrapped_lines as f32 * line_height).max(bounds.size.height), + if longest_line_width + line_number_width + RIGHT_MARGIN > bounds.size.width { + longest_line_width + line_number_width + RIGHT_MARGIN + } else { + longest_line_width + }, + (total_wrapped_lines as f32 * line_height + empty_bottom_height) + .max(bounds.size.height), ); + let mut last_layout = LastLayout { + visible_range, + visible_top, + visible_range_offset: visible_start_offset..visible_end_offset, + line_height, + wrap_width, + line_number_width, + lines: Rc::new(lines), + cursor_bounds: None, + }; + // `position_for_index` for example // // #### text @@ -584,37 +699,27 @@ impl Element for TextElement { // Calculate the scroll offset to keep the cursor in view - let (cursor_bounds, cursor_scroll_offset, _) = self.layout_cursor( - &lines, - line_height, - &mut bounds, - line_number_width, - window, - cx, - ); + let (cursor_bounds, cursor_scroll_offset, _) = + self.layout_cursor(&last_layout, &mut bounds, window, cx); + last_layout.cursor_bounds = cursor_bounds; - let selection_path = self.layout_selections( - &lines, - line_height, - &mut bounds, - line_number_width, - window, - cx, - ); + let selection_path = self.layout_selections(&last_layout, &mut bounds, cx); + let search_match_paths = vec![]; + let hover_highlight_path = None; + let line_numbers = None; + let hover_definition_hitbox = None; PrepaintState { bounds, - last_layout: LastLayout { - lines: Rc::new(lines), - line_height, - visible_range, - }, + last_layout, scroll_size, - line_numbers: None, - line_number_width, + line_numbers, cursor_bounds, cursor_scroll_offset, selection_path, + search_match_paths, + hover_highlight_path, + hover_definition_hitbox, } } @@ -628,21 +733,21 @@ impl Element for TextElement { window: &mut Window, cx: &mut App, ) { - let focus_handle = self.input.read(cx).focus_handle.clone(); + let focus_handle = self.state.read(cx).focus_handle.clone(); + let show_cursor = self.state.read(cx).show_cursor(window, cx); let focused = focus_handle.is_focused(window); let bounds = prepaint.bounds; - let selected_range = self.input.read(cx).selected_range.clone(); - let visible_range = &prepaint.last_layout.visible_range; + let selected_range = self.state.read(cx).selected_range; window.handle_input( &focus_handle, - ElementInputHandler::new(bounds, self.input.clone()), + ElementInputHandler::new(bounds, self.state.clone()), cx, ); // Set Root focused_input when self is focused if focused { - let state = self.input.clone(); + let state = self.state.clone(); if Root::read(window, cx).focused_input.as_ref() != Some(&state) { Root::update(window, cx, |root, _, cx| { root.focused_input = Some(state); @@ -653,7 +758,7 @@ impl Element for TextElement { // And reset focused_input when next_frame start window.on_next_frame({ - let state = self.input.clone(); + let state = self.state.clone(); move |window, cx| { if !focused && Root::read(window, cx).focused_input.as_ref() == Some(&state) { Root::update(window, cx, |root, _, cx| { @@ -668,13 +773,10 @@ impl Element for TextElement { let line_height = window.line_height(); let origin = bounds.origin; - let mut invisible_top_padding = px(0.); - for line in prepaint.last_layout.lines.iter().take(visible_range.start) { - invisible_top_padding += line.size(line_height).height; - } + let invisible_top_padding = prepaint.last_layout.visible_top; let mut mask_offset_y = px(0.); - if self.input.read(cx).masked { + if self.state.read(cx).masked { // Move down offset for vertical centering the ***** if cfg!(target_os = "macos") { mask_offset_y = px(3.); @@ -683,60 +785,105 @@ impl Element for TextElement { } } + // Paint active line let mut offset_y = px(0.); if let Some(line_numbers) = prepaint.line_numbers.as_ref() { offset_y += invisible_top_padding; // Each item is the normal lines. for lines in line_numbers.iter() { - for line in lines { - let p = point(origin.x, origin.y + offset_y); - let line_size = line.size(line_height); - _ = line.paint(p, line_height, TextAlign::Left, None, window, cx); - offset_y += line_size.height; - } + let height = line_height * lines.len() as f32; + offset_y += height; } } // Paint selections - if let Some(path) = prepaint.selection_path.take() { - window.paint_path(path, cx.theme().selection); + if window.is_window_active() { + let secondary_selection = cx.theme().selection; + for (path, is_active) in prepaint.search_match_paths.iter() { + window.paint_path(path.clone(), secondary_selection); + + if *is_active { + window.paint_path(path.clone(), cx.theme().selection); + } + } + + if let Some(path) = prepaint.selection_path.take() { + window.paint_path(path, cx.theme().selection); + } + + // Paint hover highlight + if let Some(path) = prepaint.hover_highlight_path.take() { + window.paint_path(path, secondary_selection); + } } // Paint text let mut offset_y = mask_offset_y + invisible_top_padding; - for line in prepaint - .last_layout - .iter() - .skip(visible_range.start) - .take(visible_range.len()) - { - let p = point(origin.x + prepaint.line_number_width, origin.y + offset_y); + for line in prepaint.last_layout.lines.iter() { + let p = point( + origin.x + prepaint.last_layout.line_number_width, + origin.y + offset_y, + ); _ = line.paint(p, line_height, TextAlign::Left, None, window, cx); offset_y += line.size(line_height).height; } - if focused { + // Paint blinking cursor + if focused && show_cursor { if let Some(mut cursor_bounds) = prepaint.cursor_bounds.take() { cursor_bounds.origin.y += prepaint.cursor_scroll_offset.y; window.paint_quad(fill(cursor_bounds, cx.theme().cursor)); } } - self.input.update(cx, |input, cx| { - input.last_layout = Some(prepaint.last_layout.clone()); - input.last_bounds = Some(bounds); - input.last_cursor_offset = Some(input.cursor_offset()); - input.set_input_bounds(input_bounds, cx); - input.last_selected_range = Some(selected_range); - input.scroll_size = prepaint.scroll_size; - input.line_number_width = prepaint.line_number_width; - input + // Paint line numbers + let mut offset_y = px(0.); + if let Some(line_numbers) = prepaint.line_numbers.as_ref() { + offset_y += invisible_top_padding; + + // Paint line number background + window.paint_quad(fill( + Bounds { + origin: input_bounds.origin, + size: size( + prepaint.last_layout.line_number_width - LINE_NUMBER_RIGHT_MARGIN, + input_bounds.size.height, + ), + }, + cx.theme().background, + )); + + // Each item is the normal lines. + for lines in line_numbers.iter() { + let p = point(input_bounds.origin.x, origin.y + offset_y); + + for line in lines { + _ = line.paint(p, line_height, window, cx); + offset_y += line_height; + } + } + } + + self.state.update(cx, |state, cx| { + state.last_layout = Some(prepaint.last_layout.clone()); + state.last_bounds = Some(bounds); + state.last_cursor = Some(state.cursor()); + state.set_input_bounds(input_bounds, cx); + state.last_selected_range = Some(selected_range); + state.scroll_size = prepaint.scroll_size; + state .scroll_handle .set_offset(prepaint.cursor_scroll_offset); + state.deferred_scroll_offset = None; + cx.notify(); }); + if let Some(hitbox) = prepaint.hover_definition_hitbox.as_ref() { + window.set_cursor_style(gpui::CursorStyle::PointingHand, hitbox); + } + self.paint_mouse_listeners(window, cx); } } diff --git a/crates/ui/src/input/mask_pattern.rs b/crates/ui/src/input/mask_pattern.rs index bd532b5b..0af15df2 100644 --- a/crates/ui/src/input/mask_pattern.rs +++ b/crates/ui/src/input/mask_pattern.rs @@ -1,378 +1,410 @@ -use gpui::SharedString; - -#[derive(Clone, PartialEq, Debug)] -pub enum MaskToken { - /// 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 { - 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, - }, - Number { - /// Group separator, e.g. "," or " " - separator: Option, - /// Number of fraction digits, e.g. 2 for 123.45 - fraction: Option, - }, -} - -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> { - 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) -> Self { - Self::Number { - separator: sep, - fraction: None, - } - } - - pub fn placeholder(&self) -> Option { - 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 = 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::() - }); - - // Reverse the integer part for easier grouping - let chars: Vec = 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 = 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 = 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(), - } - } -} +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 { + 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, + }, + Number { + /// Group separator, e.g. "," or " " + separator: Option, + /// Number of fraction digits, e.g. 2 for 123.45 + fraction: Option, + }, +} + +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> { + 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) -> Self { + Self::Number { + separator: sep, + fraction: None, + } + } + + pub fn placeholder(&self) -> Option { + 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 = 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; + } + + let sign_positions: Vec = int_part + .chars() + .enumerate() + .filter_map(|(i, ch)| match is_sign(&ch) { + true => Some(i), + false => None, + }) + .collect(); + + // only one sign is valid + // sign is only valid at the beginning of the string + if sign_positions.len() > 1 || sign_positions.first() > Some(&0) { + return false; + } + + // check if the integer part is valid + if !int_part.chars().enumerate().all(|(i, ch)| { + ch.is_ascii_digit() || is_sign(&ch) && i == 0 || 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::() + }); + + // Reverse the integer part for easier grouping + let mut chars: Vec = int_part.chars().rev().collect(); + + // Removing the sign from formatting to avoid cases such as: -,123 + let maybe_signed = chars.iter().position(is_sign).map(|pos| chars.remove(pos)); + + 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 + }; + + let final_str = if let Some(sign) = maybe_signed { + format!("{sign}{final_str}") + } else { + final_str + }; + + 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 = 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 = 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(), + } + } +} + +#[inline] +fn is_sign(ch: &char) -> bool { + matches!(ch, '+' | '-') +} diff --git a/crates/ui/src/input/mod.rs b/crates/ui/src/input/mod.rs index 0f3c6d5d..a3a07f45 100644 --- a/crates/ui/src/input/mod.rs +++ b/crates/ui/src/input/mod.rs @@ -1,13 +1,15 @@ mod blink_cursor; mod change; +mod cursor; mod element; mod mask_pattern; +mod mode; +mod rope_ext; mod state; mod text_input; mod text_wrapper; pub(crate) mod clear_button; -#[allow(ambiguous_glob_reexports)] pub use state::*; pub use text_input::*; diff --git a/crates/ui/src/input/mode.rs b/crates/ui/src/input/mode.rs new file mode 100644 index 00000000..b781262c --- /dev/null +++ b/crates/ui/src/input/mode.rs @@ -0,0 +1,129 @@ +use gpui::SharedString; + +use super::text_wrapper::TextWrapper; + +#[derive(Debug, Copy, Clone)] +pub struct TabSize { + /// Default is 2 + pub tab_size: usize, + /// Set true to use `\t` as tab indent, default is false + pub hard_tabs: bool, +} + +impl Default for TabSize { + fn default() -> Self { + Self { + tab_size: 2, + hard_tabs: false, + } + } +} + +impl TabSize { + pub(super) fn to_string(self) -> SharedString { + if self.hard_tabs { + "\t".into() + } else { + " ".repeat(self.tab_size).into() + } + } +} + +#[derive(Default, Clone)] +pub enum InputMode { + #[default] + SingleLine, + MultiLine { + tab: TabSize, + rows: usize, + }, + AutoGrow { + rows: usize, + min_rows: usize, + max_rows: usize, + }, +} + +#[allow(unused)] +impl InputMode { + #[inline] + pub(super) fn is_single_line(&self) -> bool { + matches!(self, InputMode::SingleLine) + } + + #[inline] + pub(super) fn is_auto_grow(&self) -> bool { + matches!(self, InputMode::AutoGrow { .. }) + } + + #[inline] + pub(super) fn is_multi_line(&self) -> bool { + matches!( + self, + InputMode::MultiLine { .. } | InputMode::AutoGrow { .. } + ) + } + + 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 self.is_single_line() { + return; + } + + let wrapped_lines = text_wrapper.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, + } + } + + #[inline] + pub(super) fn tab_size(&self) -> Option<&TabSize> { + match self { + InputMode::MultiLine { tab, .. } => Some(tab), + _ => None, + } + } +} diff --git a/crates/ui/src/input/rope_ext.rs b/crates/ui/src/input/rope_ext.rs new file mode 100644 index 00000000..96685c59 --- /dev/null +++ b/crates/ui/src/input/rope_ext.rs @@ -0,0 +1,207 @@ +use std::ops::Range; + +use rope::{Point, Rope}; + +use super::cursor::Position; + +/// An extension trait for `Rope` to provide additional utility methods. +pub trait RopeExt { + /// Get the line at the given row (0-based) index, including the `\r` at the end, but not `\n`. + /// + /// Return empty rope if the row (0-based) is out of bounds. + fn line(&self, row: usize) -> Rope; + + /// Start offset of the line at the given row (0-based) index. + fn line_start_offset(&self, row: usize) -> usize; + + /// Line the end offset (including `\n`) of the line at the given row (0-based) index. + /// + /// Return the end of the rope if the row is out of bounds. + fn line_end_offset(&self, row: usize) -> usize; + + /// Return the number of lines in the rope. + fn lines_len(&self) -> usize; + + /// Return the lines iterator. + /// + /// Each line is including the `\r` at the end, but not `\n`. + fn lines(&self) -> RopeLines; + + /// Check is equal to another rope. + fn eq(&self, other: &Rope) -> bool; + + /// Total number of characters in the rope. + fn chars_count(&self) -> usize; + + /// Get char at the given offset (byte). + /// + /// If the offset is in the middle of a multi-byte character will panic. + /// + /// If the offset is out of bounds, return None. + fn char_at(&self, offset: usize) -> Option; + + /// Get the byte offset from the given line, column [`Position`] (0-based). + fn position_to_offset(&self, line_col: &Position) -> usize; + + /// Get the line, column [`Position`] (0-based) from the given byte offset. + fn offset_to_position(&self, offset: usize) -> Position; + + /// Get the word byte range at the given offset (byte). + fn word_range(&self, offset: usize) -> Option>; + + /// Get word at the given offset (byte). + #[allow(dead_code)] + fn word_at(&self, offset: usize) -> String; +} + +/// An iterator over the lines of a `Rope`. +pub struct RopeLines { + row: usize, + end_row: usize, + rope: Rope, +} + +impl RopeLines { + /// Create a new `RopeLines` iterator. + pub fn new(rope: Rope) -> Self { + let end_row = rope.lines_len(); + Self { + row: 0, + end_row, + rope, + } + } +} + +impl Iterator for RopeLines { + type Item = Rope; + + #[inline] + fn next(&mut self) -> Option { + if self.row >= self.end_row { + return None; + } + + let line = self.rope.line(self.row); + self.row += 1; + Some(line) + } + + #[inline] + fn nth(&mut self, n: usize) -> Option { + self.row = self.row.saturating_add(n); + self.next() + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + let len = self.end_row - self.row; + (len, Some(len)) + } +} + +impl std::iter::ExactSizeIterator for RopeLines {} +impl std::iter::FusedIterator for RopeLines {} + +impl RopeExt for Rope { + fn line(&self, row: usize) -> Rope { + let start = self.line_start_offset(row); + let end = start + self.line_len(row as u32) as usize; + self.slice(start..end) + } + + fn line_start_offset(&self, row: usize) -> usize { + let row = row as u32; + self.point_to_offset(Point::new(row, 0)) + } + + fn position_to_offset(&self, pos: &Position) -> usize { + let line = self.line(pos.line as usize); + self.line_start_offset(pos.line as usize) + + line + .chars() + .take(pos.character as usize) + .map(|c| c.len_utf8()) + .sum::() + } + + fn offset_to_position(&self, offset: usize) -> Position { + let point = self.offset_to_point(offset); + let line = self.line(point.row as usize); + 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) + } + + fn line_end_offset(&self, row: usize) -> usize { + if row > self.max_point().row as usize { + return self.len(); + } + + self.line_start_offset(row) + self.line_len(row as u32) as usize + } + + fn lines_len(&self) -> usize { + self.max_point().row as usize + 1 + } + + fn lines(&self) -> RopeLines { + RopeLines::new(self.clone()) + } + + fn eq(&self, other: &Rope) -> bool { + self.summary() == other.summary() + } + + fn chars_count(&self) -> usize { + self.chars().count() + } + + fn char_at(&self, offset: usize) -> Option { + if offset > self.len() { + return None; + } + + let offset = self.clip_offset(offset, sum_tree::Bias::Left); + self.slice(offset..self.len()).chars().next() + } + + fn word_range(&self, offset: usize) -> Option> { + if offset >= self.len() { + return None; + } + + let offset = self.clip_offset(offset, sum_tree::Bias::Left); + + let mut left = String::new(); + for c in self.reversed_chars_at(offset) { + if c.is_alphanumeric() || c == '_' { + left.insert(0, c); + } else { + break; + } + } + let start = offset.saturating_sub(left.len()); + + let right = self + .chars_at(offset) + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect::(); + + let end = offset + right.len(); + + if start == end { + None + } else { + Some(start..end) + } + } + + fn word_at(&self, offset: usize) -> String { + if let Some(range) = self.word_range(offset) { + self.slice(range).to_string() + } else { + String::new() + } + } +} diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index 60cdff62..5ca1e579 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -1,30 +1,34 @@ -use std::ops::{Deref, Range}; +use std::ops::Range; use std::rc::Rc; use std::time::Duration; use gpui::prelude::FluentBuilder as _; use gpui::{ - actions, div, point, px, Action, App, AppContext, Bounds, ClipboardItem, Context, - DefiniteLength, 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, UTF16Selection, Window, WrappedLine, + actions, div, point, px, Action, App, AppContext, Bounds, 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, UTF16Selection, Window, WrappedLine, }; +use lsp_types::Position; +use rope::{OffsetUtf16, Rope}; use serde::Deserialize; use smallvec::SmallVec; +use sum_tree::Bias; use unicode_segmentation::*; -// TODO: -// - Move cursor to skip line eof empty chars. -use super::{ - blink_cursor::BlinkCursor, change::Change, element::TextElement, mask_pattern::MaskPattern, - text_wrapper::TextWrapper, -}; +use super::blink_cursor::BlinkCursor; +use super::change::Change; +use super::cursor::Selection; +use super::element::TextElement; +use super::mask_pattern::MaskPattern; +use super::mode::{InputMode, TabSize}; +use super::rope_ext::RopeExt; +use super::text_wrapper::{LineItem, TextWrapper}; use crate::history::History; -use crate::scroll::ScrollbarState; +use crate::input::element::RIGHT_MARGIN; use crate::Root; -/// Define a custom action for Enter button #[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[action(namespace = input, no_json)] pub struct Enter { @@ -35,88 +39,57 @@ pub struct Enter { actions!( input, [ - /// A action that will trigger when user presses backspace button Backspace, - /// Delete a chacracter Delete, - /// Delete all characters from current position to the beginning of the line DeleteToBeginningOfLine, - /// Delete all characters from current position to the end of the line DeleteToEndOfLine, - /// Delete all characters from current position to the previous word DeleteToPreviousWordStart, - /// Delete all characters from current position to the end of the next word DeleteToNextWordEnd, - /// Move the cursor to up position - Up, - /// Move the cursor to down position - Down, - /// Move the cursor to left position - Left, - /// Move the cursor to right position - Right, - /// Select up + Indent, + Outdent, + IndentInline, + OutdentInline, + MoveUp, + MoveDown, + MoveLeft, + MoveRight, + MoveHome, + MoveEnd, + MovePageUp, + MovePageDown, SelectUp, - /// Select down SelectDown, - /// Select left SelectLeft, - /// Select right SelectRight, - /// Select all characters SelectAll, - /// A action that will trigger when user presses home button - Home, - /// A action that will trigger when user presses end button - End, - /// Select all characters to the start of the line SelectToStartOfLine, - /// Select all characters to the end of the line SelectToEndOfLine, - /// Select all characters to the start of the input SelectToStart, - /// Select all characters to the start of the input SelectToEnd, - /// Select all characters to the start of the previous word SelectToPreviousWordStart, - /// Select all characters to the end of the next word SelectToNextWordEnd, - /// Show character palette ShowCharacterPalette, - /// Copy Copy, - /// Cut Cut, - /// paste Paste, - /// Undow Undo, - /// Redo Redo, - /// New line NewLine, - /// Move to the start of line MoveToStartOfLine, - /// Move to the end of line MoveToEndOfLine, - /// Move to the start of the input MoveToStart, - /// Move to the end of the input MoveToEnd, - /// Move to the previous word MoveToPreviousWord, - /// Move to the next word MoveToNextWord, - /// Trigger when text changed - TextChanged, - /// A action that will trigger when user presses escape button - Escape + Escape, + ToggleCodeActions, + Search, ] ); #[derive(Clone)] pub enum InputEvent { - Change(SharedString), + Change, PressEnter { secondary: bool }, Focus, Blur, @@ -143,16 +116,28 @@ pub fn init(cx: &mut App) { KeyBinding::new("enter", Enter { secondary: false }, Some(CONTEXT)), KeyBinding::new("secondary-enter", Enter { secondary: true }, Some(CONTEXT)), KeyBinding::new("escape", Escape, Some(CONTEXT)), - KeyBinding::new("up", Up, Some(CONTEXT)), - KeyBinding::new("down", Down, Some(CONTEXT)), - KeyBinding::new("left", Left, Some(CONTEXT)), - KeyBinding::new("right", Right, Some(CONTEXT)), + KeyBinding::new("up", MoveUp, Some(CONTEXT)), + KeyBinding::new("down", MoveDown, Some(CONTEXT)), + KeyBinding::new("left", MoveLeft, Some(CONTEXT)), + KeyBinding::new("right", MoveRight, Some(CONTEXT)), + KeyBinding::new("pageup", MovePageUp, Some(CONTEXT)), + KeyBinding::new("pagedown", MovePageDown, Some(CONTEXT)), + KeyBinding::new("tab", IndentInline, Some(CONTEXT)), + KeyBinding::new("shift-tab", OutdentInline, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-]", Indent, Some(CONTEXT)), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-]", Indent, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-[", Outdent, Some(CONTEXT)), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-[", Outdent, Some(CONTEXT)), KeyBinding::new("shift-left", SelectLeft, Some(CONTEXT)), KeyBinding::new("shift-right", SelectRight, Some(CONTEXT)), KeyBinding::new("shift-up", SelectUp, Some(CONTEXT)), KeyBinding::new("shift-down", SelectDown, Some(CONTEXT)), - KeyBinding::new("home", Home, Some(CONTEXT)), - KeyBinding::new("end", End, Some(CONTEXT)), + KeyBinding::new("home", MoveHome, Some(CONTEXT)), + KeyBinding::new("end", MoveEnd, Some(CONTEXT)), KeyBinding::new("shift-home", SelectToStartOfLine, Some(CONTEXT)), KeyBinding::new("shift-end", SelectToEndOfLine, Some(CONTEXT)), KeyBinding::new("shift-enter", NewLine, Some(CONTEXT)), @@ -191,13 +176,13 @@ pub fn init(cx: &mut App) { #[cfg(not(target_os = "macos"))] KeyBinding::new("ctrl-v", Paste, Some(CONTEXT)), #[cfg(target_os = "macos")] - KeyBinding::new("ctrl-a", Home, Some(CONTEXT)), + KeyBinding::new("ctrl-a", MoveHome, Some(CONTEXT)), #[cfg(target_os = "macos")] - KeyBinding::new("cmd-left", Home, Some(CONTEXT)), + KeyBinding::new("cmd-left", MoveHome, Some(CONTEXT)), #[cfg(target_os = "macos")] - KeyBinding::new("ctrl-e", End, Some(CONTEXT)), + KeyBinding::new("ctrl-e", MoveEnd, Some(CONTEXT)), #[cfg(target_os = "macos")] - KeyBinding::new("cmd-right", End, Some(CONTEXT)), + KeyBinding::new("cmd-right", MoveEnd, Some(CONTEXT)), #[cfg(target_os = "macos")] KeyBinding::new("cmd-z", Undo, Some(CONTEXT)), #[cfg(target_os = "macos")] @@ -222,152 +207,91 @@ pub fn init(cx: &mut App) { KeyBinding::new("ctrl-z", Undo, Some(CONTEXT)), #[cfg(not(target_os = "macos"))] KeyBinding::new("ctrl-y", Redo, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-.", ToggleCodeActions, Some(CONTEXT)), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-.", ToggleCodeActions, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-f", Search, Some(CONTEXT)), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("ctrl-f", Search, Some(CONTEXT)), ]); } -#[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, - } - } -} - #[derive(Clone)] pub(super) struct LastLayout { - /// The last layout lines. + /// The visible range (no wrap) of lines in the viewport, the value is row (0-based) index. + pub(super) visible_range: Range, + /// The first visible line top position in scroll viewport. + pub(super) visible_top: Pixels, + /// The range of byte offset of the visible lines. + pub(super) visible_range_offset: Range, + /// The last layout lines (Only have visible lines). pub(super) lines: Rc>, /// The line_height of text layout, this will change will InputElement painted. pub(super) line_height: Pixels, - /// The visible range (no wrap) of lines in the viewport. - pub(super) visible_range: Range, -} - -impl Deref for LastLayout { - type Target = Rc>; - - fn deref(&self) -> &Self::Target { - &self.lines - } + /// The wrap width of text layout, this will change will InputElement painted. + pub(super) wrap_width: Option, + /// The line number area width of text layout, if not line number, this will be 0px. + pub(super) line_number_width: Pixels, + /// The cursor position (top, left) in pixels. + pub(super) cursor_bounds: Option>, } /// 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) new_line_on_enter: bool, + pub(super) text: Rope, pub(super) text_wrapper: TextWrapper, pub(super) history: History, pub(super) blink_cursor: Entity, - pub(super) loading: bool, - pub(super) selected_range: Range, + pub loading: bool, + /// Range in UTF-8 length for the selected text. + /// + /// - "Hello 世界💝" = 16 + /// - "💝" = 4 + pub(super) selected_range: Selection, /// Range for save the selected word, use to keep word range when drag move. - pub(super) selected_word_range: Option>, + pub(super) selected_word_range: Option, pub(super) selection_reversed: bool, /// The marked range is the temporary insert text on IME typing. - pub(super) marked_range: Option>, + pub(super) ime_marked_range: Option, pub(super) last_layout: Option, - pub(super) last_cursor_offset: Option, + pub(super) last_cursor: Option, /// The input container bounds pub(super) input_bounds: Bounds, /// The text bounds pub(super) last_bounds: Option>, - pub(super) last_selected_range: Option>, + pub(super) last_selected_range: Option, pub(super) selecting: bool, pub(super) disabled: bool, pub(super) masked: bool, pub(super) clean_on_escape: bool, + pub(super) soft_wrap: bool, pub(super) pattern: Option, + pub(super) new_line_on_enter: bool, #[allow(clippy::type_complexity)] - pub(super) validate: Option bool + 'static>>, + pub(super) validate: Option) -> bool + 'static>>, pub(crate) scroll_handle: ScrollHandle, - pub(super) scrollbar_state: ScrollbarState, + /// The deferred scroll offset to apply on next layout. + pub(crate) deferred_scroll_offset: Option>, /// The size of the scrollable content. pub(crate) scroll_size: gpui::Size, - pub(crate) line_number_width: Pixels, /// The mask pattern for formatting the input text pub(crate) mask_pattern: MaskPattern, pub(super) placeholder: SharedString, + /// 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. - preferred_x_offset: Option, + /// + /// The first element is the x-coordinate (Pixels), preferred to use this. + /// The second element is the column (usize), fallback to use this. + preferred_column: Option<(Pixels, usize)>, + _subscriptions: Vec, } @@ -380,9 +304,7 @@ impl InputState { pub fn new(window: &mut Window, cx: &mut Context) -> Self { let focus_handle = cx.focus_handle(); let blink_cursor = cx.new(|_| BlinkCursor::new()); - let history = History::new() - .max_undo(2000) - .group_interval(Duration::from_millis(600)); + let history = History::new().group_interval(Duration::from_secs(1)); let _subscriptions = vec![ // Observe the blink cursor to repaint the view when it changes. @@ -412,18 +334,19 @@ impl InputState { text_style.font_size.to_pixels(window.rem_size()), None, ), - new_line_on_enter: true, blink_cursor, history, - selected_range: 0..0, + selected_range: Selection::default(), selected_word_range: None, selection_reversed: false, - marked_range: None, + ime_marked_range: None, input_bounds: Bounds::default(), selecting: false, disabled: false, masked: false, clean_on_escape: false, + soft_wrap: true, + new_line_on_enter: true, loading: false, pattern: None, validate: None, @@ -431,14 +354,14 @@ impl InputState { last_layout: None, last_bounds: None, last_selected_range: None, - last_cursor_offset: None, + last_cursor: None, scroll_handle: ScrollHandle::new(), - scrollbar_state: ScrollbarState::default(), scroll_size: gpui::size(px(0.), px(0.)), - line_number_width: px(0.), - preferred_x_offset: None, + deferred_scroll_offset: None, + preferred_column: None, placeholder: SharedString::default(), mask_pattern: MaskPattern::default(), + silent_replace_text: false, _subscriptions, } } @@ -449,7 +372,7 @@ impl InputState { pub fn multi_line(mut self) -> Self { self.mode = InputMode::MultiLine { rows: 2, - height: None, + tab: TabSize::default(), }; self } @@ -464,18 +387,43 @@ impl InputState { self } - /// Disables new lines on enter when multi-line is enabled. - pub fn prevent_new_line_on_enter(mut self) -> Self { - self.new_line_on_enter = false; - self - } - /// Set placeholder pub fn placeholder(mut self, placeholder: impl Into) -> Self { self.placeholder = placeholder.into(); self } + /// Set the tab size for the input. + /// + /// Only for [`InputMode::MultiLine`] and [`InputMode::CodeEditor`] mode. + pub fn tab_size(mut self, tab: TabSize) -> Self { + if let InputMode::MultiLine { tab: t, .. } = &mut self.mode { + *t = tab + } + self + } + + /// Set the number of rows for the multi-line Textarea. + /// + /// This is only used when `multi_line` is set to true. + /// + /// default: 2 + pub fn rows(mut self, rows: usize) -> Self { + match &mut self.mode { + InputMode::MultiLine { rows: r, .. } => *r = rows, + InputMode::AutoGrow { + max_rows: max_r, + rows: r, + .. + } => { + *r = rows; + *max_r = rows; + } + _ => {} + } + self + } + /// Set placeholder pub fn set_placeholder( &mut self, @@ -487,18 +435,27 @@ impl InputState { cx.notify(); } - /// Called after moving the cursor. Updates preferred_x_offset if we know where the cursor now is. - fn update_preferred_x_offset(&mut self, _cx: &mut Context) { - let (Some(_), Some(bounds)) = (&self.last_layout, &self.last_bounds) else { + /// Called after moving the cursor. Updates preferred_column if we know where the cursor now is. + fn update_preferred_column(&mut self) { + let Some(last_layout) = &self.last_layout else { + self.preferred_column = None; return; }; - // Find which line and sub-line the cursor is on and its position - let (_, _, cursor_pos) = self.line_and_position_for_offset(self.cursor_offset()); + let point = self.text.offset_to_point(self.cursor()); + let row = (point.row as usize).saturating_sub(last_layout.visible_range.start); + let Some(line) = last_layout.lines.get(row) else { + self.preferred_column = None; + return; + }; - if let Some(pos) = cursor_pos { - self.preferred_x_offset = Some(pos.x + bounds.origin.x); - } + let Some(pos) = line.position_for_index(point.column as usize, last_layout.line_height) + else { + self.preferred_column = None; + return; + }; + + self.preferred_column = Some((pos.x, point.column as usize)); } /// Find which line and sub-line the given offset belongs to, along with the position within that sub-line. @@ -508,6 +465,7 @@ impl InputState { /// - The index of the line (zero-based) containing the offset. /// - The index of the sub-line (zero-based) within the line containing the offset. /// - The position of the offset. + #[allow(unused)] pub(super) fn line_and_position_for_offset( &self, offset: usize, @@ -517,151 +475,72 @@ impl InputState { }; let line_height = last_layout.line_height; - let mut prev_lines_offset = 0; - let mut y_offset = px(0.); + let mut prev_lines_offset = last_layout.visible_range_offset.start; + let mut y_offset = last_layout.visible_top; + for (line_index, line) in last_layout.lines.iter().enumerate() { let local_offset = offset.saturating_sub(prev_lines_offset); if let Some(pos) = line.position_for_index(local_offset, line_height) { let sub_line_index = (pos.y.0 / line_height.0) as usize; - let adjusted_pos = point(pos.x, pos.y + y_offset); + let adjusted_pos = point(pos.x + last_layout.line_number_width, pos.y + y_offset); return (line_index, sub_line_index, Some(adjusted_pos)); } y_offset += line.size(line_height).height; prev_lines_offset += line.len() + 1; } + (0, 0, None) } /// Move the cursor vertically by one line (up or down) while preserving the column if possible. - /// direction: -1 for up, +1 for down - fn move_vertical(&mut self, direction: i32, window: &mut Window, cx: &mut Context) { - if self.is_single_line() { + /// + /// move_lines: Number of lines to move vertically (positive for down, negative for up). + fn move_vertical(&mut self, move_lines: isize, _: &mut Window, cx: &mut Context) { + if self.mode.is_single_line() { return; } - - let (Some(last_layout), Some(bounds)) = (&self.last_layout, &self.last_bounds) else { + let Some(last_layout) = &self.last_layout else { return; }; - let offset = self.cursor_offset(); - let preferred_x_offset = self.preferred_x_offset; - let line_height = last_layout.line_height; - let (current_line_index, current_sub_line, current_pos) = - self.line_and_position_for_offset(offset); + let offset = self.cursor(); + let was_preferred_column = self.preferred_column; - let Some(current_pos) = current_pos else { - return; - }; + let row = self.text.offset_to_point(offset).row; + let new_row = row.saturating_add_signed(move_lines as i32); + let line_start_offset = self.text.point_to_offset(rope::Point::new(new_row, 0)); - let current_x = self - .preferred_x_offset - .unwrap_or_else(|| current_pos.x + bounds.origin.x); + let mut new_offset = line_start_offset; - let mut new_line_index = current_line_index; - let mut new_sub_line = current_sub_line as i32; + if let Some((preferred_x, column)) = was_preferred_column { + let new_column = column.min(self.text.line(new_row as usize).len()); + new_offset = line_start_offset + new_column; - new_sub_line += direction; - - // Handle moving above the first line - if direction == -1 && new_line_index == 0 && new_sub_line < 0 { - // Move cursor to the beginning of the text - self.move_to(0, window, cx); - return; - } - - if new_sub_line < 0 { - if new_line_index > 0 { - new_line_index -= 1; - new_sub_line = last_layout.lines[new_line_index].wrap_boundaries.len() as i32; - } else { - new_sub_line = 0; - } - } else { - let max_sub_line = last_layout.lines[new_line_index].wrap_boundaries.len() as i32; - if new_sub_line > max_sub_line { - if new_line_index < last_layout.lines.len() - 1 { - new_line_index += 1; - new_sub_line = 0; - } else { - new_sub_line = max_sub_line; + // If in visible range, prefer to use position to get column. + let new_row = new_row as usize; + if new_row >= last_layout.visible_range.start { + let visible_row = new_row.saturating_sub(last_layout.visible_range.start); + if let Some(line) = last_layout.lines.get(visible_row) { + if let Ok(x) = line.closest_index_for_position( + Point { + x: preferred_x, + y: px(0.), + }, + last_layout.line_height, + ) { + new_offset = line_start_offset + x; + } } } } - - // If after adjustment, still at the same position, do not proceed - if new_line_index == current_line_index && new_sub_line == current_sub_line as i32 { - return; - } - - let target_line = &last_layout.lines[new_line_index]; - let line_x = current_x - bounds.origin.x; - let target_sub_line = new_sub_line as usize; - - let approx_pos = point(line_x, px(target_sub_line as f32 * line_height.0)); - let index_res = target_line.index_for_position(approx_pos, line_height); - - let new_local_index = match index_res { - Ok(i) => i, - Err(i) => i, - }; - - let mut prev_lines_offset = 0; - for (i, l) in last_layout.lines.iter().enumerate() { - if i == new_line_index { - break; - } - prev_lines_offset += l.len() + 1; - } - - let new_offset = (prev_lines_offset + new_local_index).min(self.text.len()); - self.selected_range = new_offset..new_offset; self.pause_blink_cursor(cx); - // Set back the preferred_x_offset - self.preferred_x_offset = preferred_x_offset; + self.move_to(new_offset, cx); + // Set back the preferred_column + self.preferred_column = was_preferred_column; cx.notify(); } - #[inline] - pub(super) fn is_multi_line(&self) -> bool { - matches!( - self.mode, - InputMode::MultiLine { .. } | InputMode::AutoGrow { .. } - ) - } - - #[inline] - pub(super) fn is_single_line(&self) -> bool { - 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. - /// - /// This is only used when `multi_line` is set to true. - /// - /// default: 2 - pub fn rows(mut self, rows: usize) -> Self { - 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 - } - /// Set the text of the input field. /// /// And the selection_range will be reset to 0..0. @@ -672,10 +551,20 @@ impl InputState { cx: &mut Context, ) { self.history.ignore = true; + let was_disabled = self.disabled; self.replace_text(value, window, cx); + self.disabled = was_disabled; self.history.ignore = false; + // Ensure cursor to start when set text - self.selected_range = self.text.len()..self.text.len(); + if self.mode.is_single_line() { + self.selected_range = (self.text.len()..self.text.len()).into(); + } else { + self.selected_range.clear(); + } + + // Move scroll to top + self.scroll_handle.set_offset(point(px(0.), px(0.))); cx.notify(); } @@ -690,9 +579,9 @@ impl InputState { cx: &mut Context, ) { let text: SharedString = text.into(); - let range_utf16 = self.range_to_utf16(&(self.cursor_offset()..self.cursor_offset())); - self.replace_text_in_range(Some(range_utf16), &text, window, cx); - self.selected_range = self.selected_range.end..self.selected_range.end; + let range_utf16 = self.range_to_utf16(&(self.cursor()..self.cursor())); + self.replace_text_in_range_silent(Some(range_utf16), &text, window, cx); + self.selected_range = (self.selected_range.end..self.selected_range.end).into(); } /// Replace text at the current cursor position. @@ -705,8 +594,8 @@ impl InputState { cx: &mut Context, ) { let text: SharedString = text.into(); - self.replace_text_in_range(None, &text, window, cx); - self.selected_range = self.selected_range.end..self.selected_range.end; + self.replace_text_in_range_silent(None, &text, window, cx); + self.selected_range = (self.selected_range.end..self.selected_range.end).into(); } fn replace_text( @@ -717,37 +606,20 @@ 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); - } - - /// Set with disabled mode. - /// - /// See also: [`Self::set_disabled`], [`Self::is_disabled`]. - pub fn disabled(mut self, disabled: bool) -> Self { - self.disabled = disabled; - self - } - - /// Set the disabled state of the input field. - /// - /// See also: [`Self::disabled`], [`Self::is_disabled`]. - pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context) { - self.disabled = disabled; - cx.notify(); - } - - /// Return is the input field is disabled. - pub fn is_disabled(&self) -> bool { - self.disabled + self.replace_text_in_range_silent(Some(range), &text, window, cx); } /// Set with password masked state. + /// + /// Only for [`InputMode::SingleLine`] mode. pub fn masked(mut self, masked: bool) -> Self { 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) { self.masked = masked; cx.notify(); @@ -759,13 +631,51 @@ impl InputState { self } + /// Set the soft wrap mode for multi-line input, default is true. + pub fn soft_wrap(mut self, wrap: bool) -> Self { + self.soft_wrap = wrap; + self + } + + /// Disables new lines on enter when multi-line is enabled. + pub fn prevent_new_line_on_enter(mut self) -> Self { + self.new_line_on_enter = false; + 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) { + self.soft_wrap = wrap; + if wrap { + let wrap_width = self + .last_layout + .as_ref() + .and_then(|b| b.wrap_width) + .unwrap_or(self.input_bounds.size.width); + + self.text_wrapper.set_wrap_width(Some(wrap_width), cx); + + // Reset scroll to left 0 + let mut offset = self.scroll_handle.offset(); + offset.x = px(0.); + self.scroll_handle.set_offset(offset); + } else { + self.text_wrapper.set_wrap_width(None, cx); + } + cx.notify(); + } + /// Set the regular expression pattern of the input field. + /// + /// Only for [`InputMode::SingleLine`] mode. pub fn pattern(mut self, pattern: regex::Regex) -> Self { 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, @@ -776,69 +686,118 @@ impl InputState { } /// Set the validation function of the input field. - pub fn validate(mut self, f: impl Fn(&str) -> bool + 'static) -> Self { + /// + /// Only for [`InputMode::SingleLine`] mode. + pub fn validate(mut self, f: impl Fn(&str, &mut Context) -> bool + 'static) -> Self { self.validate = Some(Box::new(f)); self } - /// Get the loading state of the input field. - pub fn loading(&self, _cx: &App) -> bool { - self.loading - } - /// Set true to show indicator at the input right. + /// + /// Only for [`InputMode::SingleLine`] mode. pub fn set_loading(&mut self, loading: bool, cx: &mut Context) { self.loading = loading; cx.notify(); } + /// Set with disabled mode. + /// + /// See also: [`Self::set_disabled`], [`Self::is_disabled`]. + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + /// Set with disabled mode. + pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context) { + self.disabled = disabled; + cx.notify(); + } + /// Set the default value of the input field. pub fn default_value(mut self, value: impl Into) -> Self { - self.text = value.into(); + let text: SharedString = value.into(); + self.text = Rope::from(text.as_str()); + self.text_wrapper.set_default_text(&self.text); self } /// Return the value of the input field. - pub fn value(&self) -> &SharedString { - &self.text + pub fn value(&self) -> SharedString { + SharedString::new(self.text.to_string()) } /// Return the value without mask. pub fn unmask_value(&self) -> SharedString { - self.mask_pattern.unmask(&self.text).into() + self.mask_pattern.unmask(&self.text.to_string()).into() + } + + /// Return the text [`Rope`] of the input field. + pub fn text(&self) -> &Rope { + &self.text + } + + /// Return the (0-based) [`Position`] of the cursor. + pub fn cursor_position(&self) -> Position { + let offset = self.cursor(); + self.text.offset_to_position(offset) + } + + /// Set (0-based) [`Position`] of the cursor. + /// + /// This will move the cursor to the specified line and column, and update the selection range. + pub fn set_cursor_position( + &mut self, + position: impl Into, + window: &mut Window, + cx: &mut Context, + ) { + let position: Position = position.into(); + let max_point = self.text.max_point(); + let row = position.line.min(max_point.row); + let col = position.character.min(self.text.line_len(row)); + let offset = self.text.point_to_offset(rope::Point::new(row, col)); + + self.move_to(offset, cx); + self.update_preferred_column(); + self.focus(window, cx); } /// Focus the input field. - pub fn focus(&self, window: &mut Window, _: &mut Context) { + pub fn focus(&self, window: &mut Window, cx: &mut Context) { self.focus_handle.focus(window); + self.blink_cursor.update(cx, |cursor, cx| { + cursor.start(cx); + }); } - pub(super) fn left(&mut self, _: &Left, window: &mut Window, cx: &mut Context) { + pub(super) fn left(&mut self, _: &MoveLeft, _: &mut Window, cx: &mut Context) { self.pause_blink_cursor(cx); if self.selected_range.is_empty() { - self.move_to(self.previous_boundary(self.cursor_offset()), window, cx); + self.move_to(self.previous_boundary(self.cursor()), cx); } else { - self.move_to(self.selected_range.start, window, cx) + self.move_to(self.selected_range.start, cx) } } - pub(super) fn right(&mut self, _: &Right, window: &mut Window, cx: &mut Context) { + pub(super) fn right(&mut self, _: &MoveRight, _: &mut Window, cx: &mut Context) { self.pause_blink_cursor(cx); if self.selected_range.is_empty() { - self.move_to(self.next_boundary(self.selected_range.end), window, cx); + self.move_to(self.next_boundary(self.selected_range.end), cx); } else { - self.move_to(self.selected_range.end, window, cx) + self.move_to(self.selected_range.end, cx) } } - pub(super) fn up(&mut self, _: &Up, window: &mut Window, cx: &mut Context) { - if self.is_single_line() { + pub(super) fn up(&mut self, _action: &MoveUp, window: &mut Window, cx: &mut Context) { + if self.mode.is_single_line() { return; } + if !self.selected_range.is_empty() { self.move_to( self.previous_boundary(self.selected_range.start.saturating_sub(1)), - window, cx, ); } @@ -846,80 +805,92 @@ impl InputState { self.move_vertical(-1, window, cx); } - pub(super) fn down(&mut self, _: &Down, window: &mut Window, cx: &mut Context) { - if self.is_single_line() { + pub(super) fn down(&mut self, _action: &MoveDown, window: &mut Window, cx: &mut Context) { + if self.mode.is_single_line() { return; } + if !self.selected_range.is_empty() { self.move_to( self.next_boundary(self.selected_range.end.saturating_sub(1)), - window, cx, ); } + self.pause_blink_cursor(cx); self.move_vertical(1, window, cx); } - pub(super) fn select_left( - &mut self, - _: &SelectLeft, - window: &mut Window, - cx: &mut Context, - ) { - self.select_to(self.previous_boundary(self.cursor_offset()), window, cx); - } - - pub(super) fn select_right( - &mut self, - _: &SelectRight, - window: &mut Window, - cx: &mut Context, - ) { - self.select_to(self.next_boundary(self.cursor_offset()), window, cx); - } - - pub(super) fn select_up(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context) { - if self.is_single_line() { + pub(super) fn page_up(&mut self, _: &MovePageUp, window: &mut Window, cx: &mut Context) { + if self.mode.is_single_line() { return; } - let offset = self.start_of_line(window, cx).saturating_sub(1); - self.select_to(self.previous_boundary(offset), window, cx); + + let Some(last_layout) = &self.last_layout else { + return; + }; + + let display_lines = (self.input_bounds.size.height / last_layout.line_height) as isize; + self.move_vertical(-display_lines, window, cx); } - pub(super) fn select_down( + pub(super) fn page_down( &mut self, - _: &SelectDown, + _: &MovePageDown, window: &mut Window, cx: &mut Context, ) { - if self.is_single_line() { + if self.mode.is_single_line() { return; } - let offset = (self.end_of_line(window, cx) + 1).min(self.text.len()); - self.select_to(self.next_boundary(offset), window, cx); + + let Some(last_layout) = &self.last_layout else { + return; + }; + + let display_lines = (self.input_bounds.size.height / last_layout.line_height) as isize; + self.move_vertical(display_lines, window, cx); } - pub(super) fn select_all( - &mut self, - _: &SelectAll, - window: &mut Window, - cx: &mut Context, - ) { - self.move_to(0, window, cx); - self.select_to(self.text.len(), window, cx) + pub(super) fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context) { + self.select_to(self.previous_boundary(self.cursor()), cx); } - pub(super) fn home(&mut self, _: &Home, window: &mut Window, cx: &mut Context) { + pub(super) fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context) { + self.select_to(self.next_boundary(self.cursor()), cx); + } + + pub(super) fn select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context) { + if self.mode.is_single_line() { + return; + } + let offset = self.start_of_line().saturating_sub(1); + self.select_to(self.previous_boundary(offset), cx); + } + + pub(super) fn select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context) { + if self.mode.is_single_line() { + return; + } + let offset = (self.end_of_line() + 1).min(self.text.len()); + self.select_to(self.next_boundary(offset), cx); + } + + pub(super) fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) { + self.selected_range = (0..self.text.len()).into(); + cx.notify(); + } + + pub(super) fn home(&mut self, _: &MoveHome, _: &mut Window, cx: &mut Context) { self.pause_blink_cursor(cx); - let offset = self.start_of_line(window, cx); - self.move_to(offset, window, cx); + let offset = self.start_of_line(); + self.move_to(offset, cx); } - pub(super) fn end(&mut self, _: &End, window: &mut Window, cx: &mut Context) { + pub(super) fn end(&mut self, _: &MoveEnd, _: &mut Window, cx: &mut Context) { self.pause_blink_cursor(cx); - let offset = self.end_of_line(window, cx); - self.move_to(offset, window, cx); + let offset = self.end_of_line(); + self.move_to(offset, cx); } pub(super) fn shift_to_new_line( @@ -928,122 +899,116 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { - if self.is_multi_line() { - let is_eof = self.selected_range.end == self.text.len(); - self.replace_text_in_range(None, "\n", window, cx); - - // Move cursor to the start of the next line - let mut new_offset = self.cursor_offset() - 1; - if is_eof { - new_offset += 1; - } - self.move_to(self.next_boundary(new_offset), window, cx); + if self.mode.is_multi_line() { + // Get current line indent + let indent = "".to_string(); + // Add newline and indent + let new_line_text = format!("\n{indent}"); + self.replace_text_in_range_silent(None, &new_line_text, window, cx); + self.pause_blink_cursor(cx); } } pub(super) fn move_to_start( &mut self, _: &MoveToStart, - window: &mut Window, + _: &mut Window, cx: &mut Context, ) { - self.move_to(0, window, cx); + self.move_to(0, cx); } - pub(super) fn move_to_end( - &mut self, - _: &MoveToEnd, - window: &mut Window, - cx: &mut Context, - ) { - let end = self.text.len(); - self.move_to(end, window, cx); + pub(super) fn move_to_end(&mut self, _: &MoveToEnd, _: &mut Window, cx: &mut Context) { + self.move_to(self.text.len(), cx); } pub(super) fn move_to_previous_word( &mut self, _: &MoveToPreviousWord, - window: &mut Window, + _: &mut Window, cx: &mut Context, ) { let offset = self.previous_start_of_word(); - self.move_to(offset, window, cx); + self.move_to(offset, cx); } pub(super) fn move_to_next_word( &mut self, _: &MoveToNextWord, - window: &mut Window, + _: &mut Window, cx: &mut Context, ) { let offset = self.next_end_of_word(); - self.move_to(offset, window, cx); + self.move_to(offset, cx); } pub(super) fn select_to_start( &mut self, _: &SelectToStart, - window: &mut Window, + _: &mut Window, cx: &mut Context, ) { - self.select_to(0, window, cx); + self.select_to(0, cx); } pub(super) fn select_to_end( &mut self, _: &SelectToEnd, - window: &mut Window, + _: &mut Window, cx: &mut Context, ) { let end = self.text.len(); - self.select_to(end, window, cx); + self.select_to(end, cx); } pub(super) fn select_to_start_of_line( &mut self, _: &SelectToStartOfLine, - window: &mut Window, + _: &mut Window, cx: &mut Context, ) { - let offset = self.start_of_line(window, cx); - self.select_to(self.previous_boundary(offset), window, cx); + let offset = self.start_of_line(); + self.select_to(offset, cx); } pub(super) fn select_to_end_of_line( &mut self, _: &SelectToEndOfLine, - window: &mut Window, + _: &mut Window, cx: &mut Context, ) { - let offset = self.end_of_line(window, cx); - self.select_to(self.next_boundary(offset), window, cx); + let offset = self.end_of_line(); + self.select_to(offset, cx); } pub(super) fn select_to_previous_word( &mut self, _: &SelectToPreviousWordStart, - window: &mut Window, + _: &mut Window, cx: &mut Context, ) { let offset = self.previous_start_of_word(); - self.select_to(offset, window, cx); + self.select_to(offset, cx); } pub(super) fn select_to_next_word( &mut self, _: &SelectToNextWordEnd, - window: &mut Window, + _: &mut Window, cx: &mut Context, ) { let offset = self.next_end_of_word(); - self.select_to(offset, window, cx); + self.select_to(offset, cx); } /// Return the start offset of the previous word. fn previous_start_of_word(&mut self) -> usize { let offset = self.selected_range.start; - let prev_str = self.text_for_range_utf8(0..offset); - UnicodeSegmentation::split_word_bound_indices(prev_str) + 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(); + + UnicodeSegmentation::split_word_bound_indices(left_part.as_str()) .filter(|(_, s)| !s.trim_start().is_empty()) .next_back() .map(|(i, _)| i) @@ -1052,21 +1017,49 @@ impl InputState { /// Return the next end offset of the next word. fn next_end_of_word(&mut self) -> usize { - let offset = self.cursor_offset(); - let next_str = self.text_for_range_utf8(offset..self.text.len()); - UnicodeSegmentation::split_word_bound_indices(next_str) + 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()) .find(|(_, s)| !s.trim_start().is_empty()) .map(|(i, s)| offset + i + s.len()) .unwrap_or(self.text.len()) } - /// Get start of line - fn start_of_line(&mut self, window: &mut Window, cx: &mut Context) -> usize { - if self.is_single_line() { + /// Get start of line byte offset of cursor + fn start_of_line(&self) -> usize { + if self.mode.is_single_line() { return 0; } - let offset = self.previous_boundary(self.cursor_offset()); + let row = self.text.offset_to_point(self.cursor()).row; + self.text.line_start_offset(row as usize) + } + + /// Get end of line byte offset of cursor + fn end_of_line(&self) -> usize { + if self.mode.is_single_line() { + return self.text.len(); + } + + let row = self.text.offset_to_point(self.cursor()).row; + self.text.line_end_offset(row as usize) + } + + /// Get start line of selection start or end (The min value). + /// + /// This is means is always get the first line of selection. + fn start_of_line_of_selection(&mut self, window: &mut Window, cx: &mut Context) -> usize { + if self.mode.is_single_line() { + return 0; + } + + let mut offset = + self.previous_boundary(self.selected_range.start.min(self.selected_range.end)); + if self.text.char_at(offset) == Some('\r') { + offset += 1; + } self.text_for_range(self.range_to_utf16(&(0..offset + 1)), &mut None, window, cx) .unwrap_or_default() @@ -1075,43 +1068,9 @@ impl InputState { .unwrap_or(0) } - /// Get end of line - fn end_of_line(&mut self, window: &mut Window, cx: &mut Context) -> usize { - if self.is_single_line() { - return self.text.len(); - } - - let offset = self.next_boundary(self.cursor_offset()); - - // ignore if offset is "\n" - if self - .text_for_range( - self.range_to_utf16(&(offset.saturating_sub(1)..offset)), - &mut None, - window, - cx, - ) - .unwrap_or_default() - .eq("\n") - { - return offset; - } - - self.text_for_range( - self.range_to_utf16(&(offset..self.text.len())), - &mut None, - window, - cx, - ) - .unwrap_or_default() - .find('\n') - .map(|i| i + offset) - .unwrap_or(self.text.len()) - } - pub(super) fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context) { if self.selected_range.is_empty() { - self.select_to(self.previous_boundary(self.cursor_offset()), window, cx) + self.select_to(self.previous_boundary(self.cursor()), cx) } self.replace_text_in_range(None, "", window, cx); self.pause_blink_cursor(cx); @@ -1119,7 +1078,7 @@ impl InputState { pub(super) fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context) { if self.selected_range.is_empty() { - self.select_to(self.next_boundary(self.cursor_offset()), window, cx) + self.select_to(self.next_boundary(self.cursor()), cx) } self.replace_text_in_range(None, "", window, cx); self.pause_blink_cursor(cx); @@ -1131,12 +1090,12 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { - let mut offset = self.start_of_line(window, cx); - if offset == self.cursor_offset() { + let mut offset = self.start_of_line(); + if offset == self.cursor() { offset = offset.saturating_sub(1); } - self.replace_text_in_range( - Some(self.range_to_utf16(&(offset..self.cursor_offset()))), + self.replace_text_in_range_silent( + Some(self.range_to_utf16(&(offset..self.cursor()))), "", window, cx, @@ -1151,12 +1110,12 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { - let mut offset = self.end_of_line(window, cx); - if offset == self.cursor_offset() { + let mut offset = self.end_of_line(); + if offset == self.cursor() { offset = (offset + 1).clamp(0, self.text.len()); } - self.replace_text_in_range( - Some(self.range_to_utf16(&(self.cursor_offset()..offset))), + self.replace_text_in_range_silent( + Some(self.range_to_utf16(&(self.cursor()..offset))), "", window, cx, @@ -1171,8 +1130,8 @@ impl InputState { cx: &mut Context, ) { let offset = self.previous_start_of_word(); - self.replace_text_in_range( - Some(self.range_to_utf16(&(offset..self.cursor_offset()))), + self.replace_text_in_range_silent( + Some(self.range_to_utf16(&(offset..self.cursor()))), "", window, cx, @@ -1187,8 +1146,8 @@ impl InputState { cx: &mut Context, ) { let offset = self.next_end_of_word(); - self.replace_text_in_range( - Some(self.range_to_utf16(&(self.cursor_offset()..offset))), + self.replace_text_in_range_silent( + Some(self.range_to_utf16(&(self.cursor()..offset))), "", window, cx, @@ -1197,16 +1156,16 @@ impl InputState { } pub(super) fn enter(&mut self, action: &Enter, window: &mut Window, cx: &mut Context) { - if self.is_multi_line() && self.new_line_on_enter { - let is_eof = self.selected_range.end == self.text.len(); - self.replace_text_in_range(None, "\n", window, cx); - - // Move cursor to the start of the next line - let mut new_offset = self.cursor_offset() - 1; - if is_eof { - new_offset += 1; - } - self.move_to(self.next_boundary(new_offset), window, cx); + if self.mode.is_multi_line() && self.new_line_on_enter { + // Get current line indent + let indent = "".to_string(); + // Add newline and indent + let new_line_text = format!("\n{indent}"); + 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(); } cx.emit(InputEvent::PressEnter { @@ -1214,19 +1173,176 @@ impl InputState { }); } + pub(super) fn indent_inline( + &mut self, + _: &IndentInline, + window: &mut Window, + cx: &mut Context, + ) { + self.indent(false, window, cx); + } + + pub(super) fn indent_block(&mut self, _: &Indent, window: &mut Window, cx: &mut Context) { + self.indent(true, window, cx); + } + + pub(super) fn outdent_inline( + &mut self, + _: &OutdentInline, + window: &mut Window, + cx: &mut Context, + ) { + self.outdent(false, window, cx); + } + + pub(super) fn outdent_block( + &mut self, + _: &Outdent, + window: &mut Window, + cx: &mut Context, + ) { + self.outdent(true, window, cx); + } + + pub(super) fn indent(&mut self, block: bool, window: &mut Window, cx: &mut Context) { + let Some(tab_size) = self.mode.tab_size() else { + return; + }; + + let tab_indent = tab_size.to_string(); + let selected_range = self.selected_range; + let mut added_len = 0; + let is_selected = !self.selected_range.is_empty(); + + if is_selected || block { + let start_offset = self.start_of_line_of_selection(window, cx); + let mut offset = start_offset; + + let selected_text = self + .text_for_range( + self.range_to_utf16(&(offset..selected_range.end)), + &mut None, + window, + cx, + ) + .unwrap_or("".into()); + + for line in selected_text.split('\n') { + self.replace_text_in_range_silent( + Some(self.range_to_utf16(&(offset..offset))), + &tab_indent, + window, + cx, + ); + added_len += tab_indent.len(); + // +1 for "\n", the `\r` is included in the `line`. + offset += line.len() + tab_indent.len() + 1; + } + + if is_selected { + self.selected_range = (start_offset..selected_range.end + added_len).into(); + } else { + self.selected_range = + (selected_range.start + added_len..selected_range.end + added_len).into(); + } + } else { + // Selected none + let offset = self.selected_range.start; + self.replace_text_in_range_silent( + Some(self.range_to_utf16(&(offset..offset))), + &tab_indent, + window, + cx, + ); + added_len = tab_indent.len(); + + self.selected_range = + (selected_range.start + added_len..selected_range.end + added_len).into(); + } + } + + pub(super) fn outdent(&mut self, block: bool, window: &mut Window, cx: &mut Context) { + let Some(tab_size) = self.mode.tab_size() else { + return; + }; + + let tab_indent = tab_size.to_string(); + let selected_range = self.selected_range; + let mut removed_len = 0; + let is_selected = !self.selected_range.is_empty(); + + if is_selected || block { + let start_offset = self.start_of_line_of_selection(window, cx); + let mut offset = start_offset; + + let selected_text = self + .text_for_range( + self.range_to_utf16(&(offset..selected_range.end)), + &mut None, + window, + cx, + ) + .unwrap_or("".into()); + + for line in selected_text.split('\n') { + if line.starts_with(tab_indent.as_ref()) { + self.replace_text_in_range_silent( + Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))), + "", + window, + cx, + ); + removed_len += tab_indent.len(); + + // +1 for "\n" + offset += line.len().saturating_sub(tab_indent.len()) + 1; + } else { + offset += line.len() + 1; + } + } + + if is_selected { + self.selected_range = + (start_offset..selected_range.end.saturating_sub(removed_len)).into(); + } else { + self.selected_range = (selected_range.start.saturating_sub(removed_len) + ..selected_range.end.saturating_sub(removed_len)) + .into(); + } + } else { + // 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 + .slice(offset..self.text.len()) + .to_string() + .starts_with(tab_indent.as_ref()) + { + self.replace_text_in_range_silent( + Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))), + "", + window, + cx, + ); + removed_len = tab_indent.len(); + let new_offset = start_offset.saturating_sub(removed_len); + self.selected_range = (new_offset..new_offset).into(); + } + } + } + pub(super) fn clean(&mut self, window: &mut Window, cx: &mut Context) { self.replace_text("", window, cx); } - pub(super) fn escape(&mut self, _: &Escape, window: &mut Window, cx: &mut Context) { - if self.marked_range.is_some() { + pub(super) fn escape(&mut self, _action: &Escape, window: &mut Window, cx: &mut Context) { + if self.ime_marked_range.is_some() { self.unmark_text(window, cx); } - if !self.selected_range.is_empty() { - return self.unselect(window, cx); - } - if self.clean_on_escape { return self.clean(window, cx); } @@ -1240,24 +1356,15 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { - if event.button == MouseButton::Middle { - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - self.paste_from_primary(window, cx); - #[cfg(not(any(target_os = "linux", target_os = "freebsd")))] - self.paste(&Paste, window, cx); - return; - } - // If there have IME marked range and is empty (Means pressed Esc to abort IME typing) // Clear the marked range. - if let Some(marked_range) = &self.marked_range { - if marked_range.is_empty() { - self.marked_range = None; + if let Some(ime_marked_range) = &self.ime_marked_range { + if ime_marked_range.is_empty() { + self.ime_marked_range = None; } } self.selecting = true; - let offset = self.index_for_mouse_position(event.position, window, cx); // Double click to select word @@ -1273,9 +1380,9 @@ impl InputState { } if event.modifiers.shift { - self.select_to(offset, window, cx); + self.select_to(offset, cx); } else { - self.move_to(offset, window, cx) + self.move_to(offset, cx) } } @@ -1312,12 +1419,74 @@ impl InputState { let safe_x_range = (-self.scroll_size.width + self.input_bounds.size.width).min(px(0.0))..px(0.); - offset.y = offset.y.clamp(safe_y_range.start, safe_y_range.end); + offset.y = if self.mode.is_single_line() { + px(0.) + } else { + offset.y.clamp(safe_y_range.start, safe_y_range.end) + }; offset.x = offset.x.clamp(safe_x_range.start, safe_x_range.end); self.scroll_handle.set_offset(offset); cx.notify(); } + pub(crate) fn scroll_to(&mut self, offset: usize, cx: &mut Context) { + let Some(last_layout) = self.last_layout.as_ref() else { + return; + }; + + let Some(bounds) = self.last_bounds.as_ref() else { + return; + }; + + let mut scroll_offset = self.scroll_handle.offset(); + let line_height = last_layout.line_height; + + let point = self.text.offset_to_point(offset); + let row = point.row as usize; + + let mut row_offset_y = px(0.); + for (ix, wrap_line) in self.text_wrapper.lines.iter().enumerate() { + if ix == row { + break; + } + + row_offset_y += wrap_line.height(line_height); + } + + if let Some(line) = last_layout + .lines + .get(row.saturating_sub(last_layout.visible_range.start)) + { + // Check to scroll horizontally + if let Some(pos) = line.position_for_index(point.column as usize, line_height) { + let bounds_width = bounds.size.width - last_layout.line_number_width; + let col_offset_x = pos.x; + if col_offset_x - RIGHT_MARGIN < -scroll_offset.x { + // If the position is out of the visible area, scroll to make it visible + scroll_offset.x = -col_offset_x + RIGHT_MARGIN; + } else if col_offset_x + RIGHT_MARGIN > -scroll_offset.x + bounds_width { + scroll_offset.x = -(col_offset_x - bounds_width + RIGHT_MARGIN); + } + } + } + + // Check if row_offset_y is out of the viewport + // If row offset is not in the viewport, scroll to make it visible + let edge_height = 3 * line_height; + if row_offset_y - edge_height < -scroll_offset.y { + // Scroll up + 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 + edge_height); + } + + 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( &mut self, _: &ShowCharacterPalette, @@ -1332,9 +1501,7 @@ impl InputState { return; } - let selected_text = self - .text_for_range_utf8(self.selected_range.clone()) - .to_string(); + let selected_text = self.text.slice(self.selected_range.into()).to_string(); cx.write_to_clipboard(ClipboardItem::new_string(selected_text)); } @@ -1343,69 +1510,49 @@ impl InputState { return; } - let selected_text = self - .text_for_range_utf8(self.selected_range.clone()) - .to_string(); + 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) { - if let Some(clipboard) = cx.read_from_clipboard() { + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + let read_clipboard = cx.read_from_primary(); + #[cfg(any(target_os = "windows", target_os = "macos"))] + let read_clipboard = cx.read_from_clipboard(); + + if let Some(clipboard) = read_clipboard { let mut new_text = clipboard.text().unwrap_or_default(); - if !self.is_multi_line() { + if !self.mode.is_multi_line() { 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); } } - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - pub(super) fn paste_from_primary(&mut self, window: &mut Window, cx: &mut Context) { - if let Some(clipboard) = cx.read_from_primary() { - let mut new_text = clipboard.text().unwrap_or_default(); - - if !self.is_multi_line() { - new_text = new_text.replace('\n', ""); - } - - self.replace_text_in_range(None, &new_text, window, cx); - } - } - - fn push_history( - &mut self, - range: &Range, - new_text: &str, - window: &mut Window, - cx: &mut Context, - ) { + fn push_history(&mut self, text: &Rope, range: &Range, new_text: &str) { if self.history.ignore { return; } - let old_text = self - .text_for_range(self.range_to_utf16(range), &mut None, window, cx) - .unwrap_or("".to_string()); + let old_text = text.slice(range.clone()).to_string(); let new_range = range.start..range.start + new_text.len(); - self.history.push(Change::new( - range.clone(), - &old_text, - new_range.clone(), - new_text, - )); + self.history + .push(Change::new(range.clone(), &old_text, new_range, new_text)); } pub(super) fn undo(&mut self, _: &Undo, window: &mut Window, cx: &mut Context) { self.history.ignore = true; if let Some(changes) = self.history.undo() { for change in changes { - let range_utf16 = self.range_to_utf16(&change.new_range); - self.replace_text_in_range(Some(range_utf16), &change.old_text, window, cx); + let range_utf16 = self.range_to_utf16(&change.new_range.into()); + self.replace_text_in_range_silent(Some(range_utf16), &change.old_text, window, cx); } } self.history.ignore = false; @@ -1415,8 +1562,8 @@ impl InputState { self.history.ignore = true; if let Some(changes) = self.history.redo() { for change in changes { - let range_utf16 = self.range_to_utf16(&change.old_range); - self.replace_text_in_range(Some(range_utf16), &change.new_text, window, cx); + let range_utf16 = self.range_to_utf16(&change.old_range.into()); + self.replace_text_in_range_silent(Some(range_utf16), &change.new_text, window, cx); } } self.history.ignore = false; @@ -1427,17 +1574,21 @@ impl InputState { /// The offset is the UTF-8 offset. /// /// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset. - fn move_to(&mut self, offset: usize, _window: &mut Window, cx: &mut Context) { + pub(crate) fn move_to(&mut self, offset: usize, cx: &mut Context) { let offset = offset.clamp(0, self.text.len()); - self.selected_range = offset..offset; + self.selected_range = (offset..offset).into(); + self.scroll_to(offset, cx); self.pause_blink_cursor(cx); - self.update_preferred_x_offset(cx); + self.update_preferred_column(); cx.notify() } - pub(super) fn cursor_offset(&self) -> usize { - if let Some(marked_range) = &self.marked_range { - return marked_range.end; + /// Get byte offset of the cursor. + /// + /// The offset is the UTF-8 offset. + pub fn cursor(&self) -> usize { + if let Some(ime_marked_range) = &self.ime_marked_range { + return ime_marked_range.end; } if self.selection_reversed { @@ -1447,7 +1598,7 @@ impl InputState { } } - fn index_for_mouse_position( + pub(crate) fn index_for_mouse_position( &self, position: Point, _window: &Window, @@ -1465,6 +1616,7 @@ impl InputState { }; let line_height = last_layout.line_height; + let line_number_width = last_layout.line_number_width; // TIP: About the IBeam cursor // @@ -1476,25 +1628,38 @@ impl InputState { // // - included the input padding. // - included the scroll offset. - let inner_position = position - bounds.origin; + let inner_position = position - bounds.origin - point(line_number_width, px(0.)); - let mut index = 0; - let mut y_offset = px(0.); - - for line in last_layout.iter() { + let mut index = last_layout.visible_range_offset.start; + let mut y_offset = last_layout.visible_top; + for (ix, line) in self + .text_wrapper + .lines + .iter() + .skip(last_layout.visible_range.start) + .enumerate() + { let line_origin = self.line_origin_with_y_offset(&mut y_offset, line, line_height); let pos = inner_position - line_origin; + let Some(rendered_line) = last_layout.lines.get(ix) else { + if pos.y < line_origin.y + line_height { + break; + } + + continue; + }; + // Return offset by use closest_index_for_x if is single line mode. - if self.is_single_line() { - return line.unwrapped_layout.closest_index_for_x(pos.x); + if self.mode.is_single_line() { + return rendered_line.unwrapped_layout.closest_index_for_x(pos.x); } - let index_result = line.closest_index_for_position(pos, line_height); + let index_result = rendered_line.closest_index_for_position(pos, line_height); if let Ok(v) = index_result { index += v; break; - } else if line + } else if rendered_line .index_for_position(point(px(0.), pos.y), line_height) .is_ok() { @@ -1502,19 +1667,19 @@ impl InputState { // The fallback index is saved in Err from `index_for_position` method. index += index_result.unwrap_err(); break; - } else if line.text.trim_end_matches('\r').is_empty() { + } else if rendered_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(); + index += rendered_line.len(); if line_bounds.contains(&pos) { break; } } else { - index += line.len(); + index += rendered_line.len(); } // +1 for revert `lines` split `\n` @@ -1532,17 +1697,16 @@ impl InputState { fn line_origin_with_y_offset( &self, y_offset: &mut Pixels, - line: &WrappedLine, + line: &LineItem, line_height: Pixels, ) -> Point { // NOTE: About line.wrap_boundaries.len() // // If only 1 line, the value is 0 // If have 2 line, the value is 1 - if self.is_multi_line() { + if self.mode.is_multi_line() { let p = point(px(0.), *y_offset); - let height = line_height + line.wrap_boundaries.len() as f32 * line_height; - *y_offset += height; + *y_offset += line.height(line_height); p } else { point(px(0.), px(0.)) @@ -1554,9 +1718,8 @@ impl InputState { /// The offset is the UTF-8 offset. /// /// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset. - fn select_to(&mut self, offset: usize, _window: &mut Window, cx: &mut Context) { + pub(crate) fn select_to(&mut self, offset: usize, cx: &mut Context) { let offset = offset.clamp(0, self.text.len()); - if self.selection_reversed { self.selected_range.start = offset } else { @@ -1565,7 +1728,7 @@ impl InputState { if self.selected_range.end < self.selected_range.start { self.selection_reversed = !self.selection_reversed; - self.selected_range = self.selected_range.end..self.selected_range.start; + self.selected_range = (self.selected_range.end..self.selected_range.start).into(); } // Ensure keep word selected range @@ -1577,23 +1740,24 @@ impl InputState { self.selected_range.end = word_range.end; } } - if self.selected_range.is_empty() { - self.update_preferred_x_offset(cx); + self.update_preferred_column(); } - cx.notify() } /// Select the word at the given offset. /// /// The offset is the UTF-8 offset. + /// + /// FIXME: When click on a non-word character, the word is not selected. fn select_word(&mut self, offset: usize, window: &mut Window, cx: &mut Context) { + #[inline(always)] fn is_word(c: char) -> bool { c.is_alphanumeric() || matches!(c, '_') } - let mut start = self.offset_to_utf16(offset); + let mut start = offset; let mut end = start; let prev_text = self @@ -1635,82 +1799,71 @@ impl InputState { return; } - self.selected_range = start..end; - self.selected_word_range = Some(self.selected_range.clone()); - + self.selected_range = (start..end).into(); + self.selected_word_range = Some(self.selected_range); cx.notify() } /// Selects the entire line containing the cursor. - fn select_line(&mut self, window: &mut Window, cx: &mut Context) { - let offset = self.start_of_line(window, cx); - let end = self.end_of_line(window, cx); - self.move_to(end, window, cx); - self.select_to(offset, window, cx); + fn select_line(&mut self, _window: &mut Window, cx: &mut Context) { + let offset = self.start_of_line(); + let end = self.end_of_line(); + self.move_to(end, cx); + self.select_to(offset, cx); } - fn unselect(&mut self, _: &mut Window, cx: &mut Context) { - let offset = self.next_boundary(self.cursor_offset()); - self.selected_range = offset..offset; + /// Unselects the currently selected text. + pub fn unselect(&mut self, _: &mut Window, cx: &mut Context) { + let offset = self.cursor(); + self.selected_range = (offset..offset).into(); cx.notify() } - fn offset_from_utf16(&self, offset: usize) -> usize { - let mut utf8_offset = 0; - let mut utf16_count = 0; - - for ch in self.text.chars() { - if utf16_count >= offset { - break; - } - utf16_count += ch.len_utf16(); - utf8_offset += ch.len_utf8(); - } - - utf8_offset + #[inline] + pub(super) fn offset_from_utf16(&self, offset: usize) -> usize { + self.text.offset_utf16_to_offset(OffsetUtf16(offset)) } - fn offset_to_utf16(&self, offset: usize) -> usize { - let mut utf16_offset = 0; - let mut utf8_count = 0; - - for ch in self.text.chars() { - if utf8_count >= offset { - break; - } - utf8_count += ch.len_utf8(); - utf16_offset += ch.len_utf16(); - } - - utf16_offset + #[inline] + pub(super) fn offset_to_utf16(&self, offset: usize) -> usize { + self.text.offset_to_offset_utf16(offset).0 } - fn range_to_utf16(&self, range: &Range) -> Range { + #[inline] + pub(super) fn range_to_utf16(&self, range: &Range) -> Range { self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end) } - fn range_from_utf16(&self, range_utf16: &Range) -> Range { + #[inline] + pub(super) fn range_from_utf16(&self, range_utf16: &Range) -> Range { self.offset_from_utf16(range_utf16.start)..self.offset_from_utf16(range_utf16.end) } fn previous_boundary(&self, offset: usize) -> usize { - self.text - .grapheme_indices(true) - .rev() - .find_map(|(idx, _)| (idx < offset).then_some(idx)) - .unwrap_or(0) + let mut offset = self.text.clip_offset(offset.saturating_sub(1), Bias::Left); + if let Some(ch) = self.text.char_at(offset) { + if ch == '\r' { + offset -= 1; + } + } + + offset } fn next_boundary(&self, offset: usize) -> usize { - self.text - .grapheme_indices(true) - .find_map(|(idx, _)| (idx > offset).then_some(idx)) - .unwrap_or(self.text.len()) + let mut offset = self.text.clip_offset(offset + 1, Bias::Right); + if let Some(ch) = self.text.char_at(offset) { + if ch == '\r' { + offset += 1; + } + } + + offset } /// Returns the true to let InputElement to render cursor, when Input is focused and current BlinkCursor is visible. pub(crate) fn show_cursor(&self, window: &Window, cx: &App) -> bool { - self.focus_handle.is_focused(window) + (self.focus_handle.is_focused(window)) && self.blink_cursor.read(cx).visible() && window.is_window_active() } @@ -1723,7 +1876,6 @@ impl InputState { } fn on_blur(&mut self, window: &mut Window, cx: &mut Context) { - self.unselect(window, cx); self.blink_cursor.update(cx, |cursor, cx| { cursor.stop(cx); }); @@ -1766,16 +1918,16 @@ impl InputState { } let offset = self.index_for_mouse_position(event.position, window, cx); - self.select_to(offset, window, cx); + self.select_to(offset, cx); } - fn is_valid_input(&self, new_text: &str) -> bool { + fn is_valid_input(&self, new_text: &str, cx: &mut Context) -> bool { if new_text.is_empty() { return true; } if let Some(validate) = &self.validate { - if !validate(new_text) { + if !validate(new_text, cx) { return false; } } @@ -1784,10 +1936,11 @@ impl InputState { return false; } - self.pattern - .as_ref() - .map(|p| p.is_match(new_text)) - .unwrap_or(true) + let Some(pattern) = &self.pattern else { + return true; + }; + + pattern.is_match(new_text) } /// Set the mask pattern for formatting the input text. @@ -1825,16 +1978,56 @@ impl InputState { 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); + if let Some(last_layout) = self.last_layout.as_ref() { + if wrap_width_changed { + let wrap_width = if !self.soft_wrap { + // None to disable wrapping (will use Pixels::MAX) + None + } else { + last_layout.wrap_width + }; + + self.text_wrapper.set_wrap_width(wrap_width, cx); + self.mode.update_auto_grow(&self.text_wrapper); + cx.notify(); + } } } - pub(crate) fn text_for_range_utf8(&mut self, range: impl Into>) -> &str { - let range = self.range_from_utf16(&self.range_to_utf16(&range.into())); - &self.text[range] + /// Replace text by [`lsp_types::Range`]. + /// + /// See also: [`EntityInputHandler::replace_text_in_range`] + #[allow(unused)] + pub(crate) fn replace_text_in_lsp_range( + &mut self, + lsp_range: &lsp_types::Range, + new_text: &str, + window: &mut Window, + cx: &mut Context, + ) { + 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_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; } } @@ -1848,7 +2041,7 @@ impl EntityInputHandler for InputState { ) -> Option { let range = self.range_from_utf16(&range_utf16); adjusted_range.replace(self.range_to_utf16(&range)); - Some(self.text[range].to_string()) + Some(self.text.slice(range).to_string()) } fn selected_text_range( @@ -1858,7 +2051,7 @@ impl EntityInputHandler for InputState { _cx: &mut Context, ) -> Option { Some(UTF16Selection { - range: self.range_to_utf16(&self.selected_range), + range: self.range_to_utf16(&self.selected_range.into()), reversed: false, }) } @@ -1868,13 +2061,12 @@ impl EntityInputHandler for InputState { _window: &mut Window, _cx: &mut Context, ) -> Option> { - self.marked_range - .as_ref() - .map(|range| self.range_to_utf16(range)) + self.ime_marked_range + .map(|range| self.range_to_utf16(&range.into())) } fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) { - self.marked_range = None; + self.ime_marked_range = None; } /// Replace text in range. @@ -1885,42 +2077,54 @@ impl EntityInputHandler for InputState { &mut self, range_utf16: Option>, new_text: &str, - window: &mut Window, + _window: &mut Window, cx: &mut Context, ) { if self.disabled { return; } + self.pause_blink_cursor(cx); + let range = range_utf16 .as_ref() .map(|range_utf16| self.range_from_utf16(range_utf16)) - .or(self.marked_range.clone()) - .unwrap_or(self.selected_range.clone()); + .or(self.ime_marked_range.map(|range| { + let range = self.range_to_utf16(&(range.start..range.end)); + self.range_from_utf16(&range) + })) + .unwrap_or(self.selected_range.into()); - let pending_text: SharedString = (self.text_for_range_utf8(0..range.start).to_owned() - + new_text - + self.text_for_range_utf8(range.end..self.text.len())) - .into(); + let old_text = self.text.clone(); + self.text.replace(range.clone(), new_text); - // Check if the new text is valid - if !self.is_valid_input(&pending_text) { - return; + let mut new_offset = (range.start + new_text.len()).min(self.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()); + } } - let mask_text = self.mask_pattern.mask(&pending_text); - let new_text_len = (new_text.len() + mask_text.len()).saturating_sub(pending_text.len()); - 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, false, 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.push_history(&old_text, &range, new_text); + self.text_wrapper + .update(&self.text, &range, &Rope::from(new_text), false, cx); + self.selected_range = (new_offset..new_offset).into(); + self.ime_marked_range.take(); + self.update_preferred_column(); self.mode.update_auto_grow(&self.text_wrapper); - cx.emit(InputEvent::Change(self.unmask_value())); + cx.emit(InputEvent::Change); cx.notify(); } @@ -1930,7 +2134,7 @@ impl EntityInputHandler for InputState { range_utf16: Option>, new_text: &str, new_selected_range_utf16: Option>, - window: &mut Window, + _: &mut Window, cx: &mut Context, ) { if self.disabled { @@ -1940,40 +2144,45 @@ impl EntityInputHandler for InputState { let range = range_utf16 .as_ref() .map(|range_utf16| self.range_from_utf16(range_utf16)) - .or(self.marked_range.clone()) - .unwrap_or(self.selected_range.clone()); + .or(self.ime_marked_range.map(|range| { + let range = self.range_to_utf16(&(range.start..range.end)); + self.range_from_utf16(&range) + })) + .unwrap_or(self.selected_range.into()); - let pending_text: SharedString = (self.text_for_range_utf8(0..range.start).to_owned() - + new_text - + self.text_for_range_utf8(range.end..self.text.len())) - .into(); + let old_text = self.text.clone(); + self.text.replace(range.clone(), new_text); - if !self.is_valid_input(&pending_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(&range, new_text, window, cx); - self.text = pending_text; - self.text_wrapper.update(&self.text, false, cx); + self.push_history(&old_text, &range, new_text); + self.text_wrapper + .update(&self.text, &range, &Rope::from(new_text), false, cx); if new_text.is_empty() { // Cancel selection, when cancel IME input. - self.selected_range = range.start..range.start; - self.marked_range = None; + self.selected_range = (range.start..range.start).into(); + self.ime_marked_range = None; } else { - self.marked_range = Some(range.start..range.start + new_text.len()); + self.ime_marked_range = Some((range.start..range.start + new_text.len()).into()); self.selected_range = new_selected_range_utf16 .as_ref() .map(|range_utf16| self.range_from_utf16(range_utf16)) .map(|new_range| new_range.start + range.start..new_range.end + range.end) - .unwrap_or_else(|| range.start + new_text.len()..range.start + new_text.len()); + .unwrap_or_else(|| range.start + new_text.len()..range.start + new_text.len()) + .into(); } self.mode.update_auto_grow(&self.text_wrapper); - cx.emit(InputEvent::Change(self.unmask_value())); + cx.emit(InputEvent::Change); cx.notify(); } /// Used to position IME candidates. - /// TODO: Fix position of IME candidates in multi-line text input. fn bounds_for_range( &mut self, range_utf16: Range, @@ -1983,12 +2192,14 @@ impl EntityInputHandler for InputState { ) -> Option> { let last_layout = self.last_layout.as_ref()?; let line_height = last_layout.line_height; + let line_number_width = last_layout.line_number_width; let range = self.range_from_utf16(&range_utf16); let mut start_origin = None; let mut end_origin = None; - let mut y_offset = px(0.); - let mut index_offset = 0; + let line_number_origin = point(line_number_width, px(0.)); + let mut y_offset = last_layout.visible_top; + let mut index_offset = last_layout.visible_range_offset.start; for line in last_layout.lines.iter() { if start_origin.is_some() && end_origin.is_some() { @@ -2021,9 +2232,9 @@ impl EntityInputHandler for InputState { end_origin.y = start_origin.y; Some(Bounds::from_corners( - bounds.origin + start_origin, + bounds.origin + line_number_origin + start_origin, // + line_height for show IME panel under the cursor line. - bounds.origin + point(end_origin.x, end_origin.y + line_height), + bounds.origin + line_number_origin + point(end_origin.x, end_origin.y + line_height), )) } @@ -2036,10 +2247,11 @@ impl EntityInputHandler for InputState { let last_layout = self.last_layout.as_ref()?; let line_height = last_layout.line_height; let line_point = self.last_bounds?.localize(&point)?; + let offset = last_layout.visible_range_offset.start; for line in last_layout.lines.iter() { if let Ok(utf8_index) = line.index_for_position(line_point, line_height) { - return Some(self.offset_to_utf16(utf8_index)); + return Some(self.offset_to_utf16(offset + utf8_index)); } } @@ -2055,12 +2267,12 @@ impl Focusable for InputState { impl Render for InputState { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - self.text_wrapper.update(&self.text, false, cx); + self.text_wrapper.update_all(&self.text, false, cx); div() .id("input-state") .flex_1() - .when(self.is_multi_line(), |this| this.h_full()) + .when(self.mode.is_multi_line(), |this| this.h_full()) .flex_grow() .overflow_x_hidden() .child(TextElement::new(cx.entity().clone()).placeholder(self.placeholder.clone())) diff --git a/crates/ui/src/input/text_input.rs b/crates/ui/src/input/text_input.rs index 4d88f3d7..60737f3a 100644 --- a/crates/ui/src/input/text_input.rs +++ b/crates/ui/src/input/text_input.rs @@ -6,11 +6,10 @@ use gpui::{ }; use theme::ActiveTheme; -use super::InputState; -use crate::button::{Button, ButtonVariants as _}; +use super::clear_button::clear_button; +use super::state::{InputState, CONTEXT}; +use crate::button::{Button, ButtonVariants}; use crate::indicator::Indicator; -use crate::input::clear_button::clear_button; -use crate::scroll::{Scrollbar, ScrollbarAxis}; use crate::{h_flex, IconName, Sizable, Size, StyleSized, StyledExt}; #[derive(IntoElement)] @@ -18,7 +17,6 @@ pub struct TextInput { state: Entity, style: StyleRefinement, size: Size, - no_gap: bool, prefix: Option, suffix: Option, height: Option, @@ -26,6 +24,8 @@ pub struct TextInput { cleanable: bool, mask_toggle: bool, disabled: bool, + bordered: bool, + focus_bordered: bool, } impl Sizable for TextInput { @@ -40,9 +40,8 @@ impl TextInput { pub fn new(state: &Entity) -> Self { Self { state: state.clone(), - style: StyleRefinement::default(), size: Size::default(), - no_gap: false, + style: StyleRefinement::default(), prefix: None, suffix: None, height: None, @@ -50,6 +49,8 @@ impl TextInput { cleanable: false, mask_toggle: false, disabled: false, + bordered: true, + focus_bordered: true, } } @@ -75,12 +76,24 @@ impl TextInput { self } - /// Set the appearance of the input field. + /// Set the appearance of the input field, if false the input field will no border, background. pub fn appearance(mut self, appearance: bool) -> Self { self.appearance = appearance; self } + /// Set the bordered for the input, default: true + pub fn bordered(mut self, bordered: bool) -> Self { + self.bordered = bordered; + self + } + + /// Set focus border for the input, default is true. + pub fn focus_bordered(mut self, bordered: bool) -> Self { + self.focus_bordered = bordered; + self + } + /// Set true to show the clear button when the input field is not empty. pub fn cleanable(mut self) -> Self { self.cleanable = true; @@ -99,15 +112,6 @@ impl TextInput { 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) -> impl IntoElement { Button::new("toggle-mask") .icon(IconName::Eye) @@ -132,44 +136,51 @@ impl TextInput { } } +impl Styled for TextInput { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + impl RenderOnce for TextInput { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { const LINE_HEIGHT: Rems = Rems(1.25); + let font = window.text_style().font(); + let font_size = window.text_style().font_size.to_pixels(window.rem_size()); - self.state.update(cx, |state, _| { - state.mode.set_height(self.height); + self.state.update(cx, |state, cx| { + state.text_wrapper.set_font(font, font_size, cx); 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 { + let 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 }; + let prefix = self.prefix; + let suffix = self.suffix; + + let show_clear_button = self.cleanable + && !state.loading + && !state.text.is_empty() + && state.mode.is_single_line(); + + let has_suffix = suffix.is_some() || state.loading || self.mask_toggle || show_clear_button; + div() .id(("input", self.state.entity_id())) .flex() - .key_context(crate::input::CONTEXT) + .key_context(CONTEXT) .track_focus(&state.focus_handle) .when(!state.disabled, |this| { this.on_action(window.listener_for(&self.state, InputState::backspace)) @@ -182,17 +193,31 @@ impl RenderOnce for TextInput { .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::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)) + .when(state.mode.is_multi_line(), |this| { + this.on_action(window.listener_for(&self.state, InputState::indent_inline)) + .on_action(window.listener_for(&self.state, InputState::outdent_inline)) + .on_action(window.listener_for(&self.state, InputState::indent_block)) + .on_action(window.listener_for(&self.state, InputState::outdent_block)) + .on_action( + window.listener_for(&self.state, InputState::shift_to_new_line), + ) + }) }) .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.is_multi_line(), |this| { + .when(state.mode.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)) .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::page_up)) + .on_action(window.listener_for(&self.state, InputState::page_down)) }) .on_action(window.listener_for(&self.state, InputState::select_all)) .on_action(window.listener_for(&self.state, InputState::select_to_start_of_line)) @@ -209,90 +234,69 @@ impl RenderOnce for TextInput { .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_down( - MouseButton::Middle, + MouseButton::Right, window.listener_for(&self.state, InputState::on_mouse_down), ) .on_mouse_up( MouseButton::Left, window.listener_for(&self.state, InputState::on_mouse_up), ) + .on_mouse_up( + MouseButton::Right, + 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_px(self.size) .input_py(self.size) .input_h(self.size) - .when(state.is_multi_line(), |this| { + .cursor_text() + .text_size(font_size) + .items_center() + .when(state.mode.is_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)) + this.bg(bg).rounded(cx.theme().radius) }) - .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| { - if state.last_layout.is_some() { - this.relative().child( - div() - .absolute() - .top_0() - .left_0() - .right(px(1.)) - .bottom_0() - .child( - Scrollbar::vertical(&state.scrollbar_state, &state.scroll_handle) - .axis(ScrollbarAxis::Vertical), - ), - ) - } else { - this - } - }) .refine_style(&self.style) + .children(prefix) + .child(self.state.clone()) + .when(has_suffix, |this| { + this.pr_2().child( + h_flex() + .id("suffix") + .gap(gap_x) + .when(self.appearance, |this| this.bg(bg)) + .items_center() + .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), + ) + }) } } diff --git a/crates/ui/src/input/text_wrapper.rs b/crates/ui/src/input/text_wrapper.rs index 46ef9100..57395ed2 100644 --- a/crates/ui/src/input/text_wrapper.rs +++ b/crates/ui/src/input/text_wrapper.rs @@ -1,99 +1,215 @@ -use std::ops::Range; - -use gpui::{App, Font, LineFragment, Pixels, SharedString}; - -#[allow(unused)] -pub(super) struct LineWrap { - /// The number of soft wrapped lines of this line (Not include first line.) - pub(super) wrap_lines: usize, - /// The range of the line text in the entire text. - pub(super) range: Range, -} - -impl LineWrap { - pub(super) fn height(&self, line_height: Pixels) -> Pixels { - line_height * (self.wrap_lines + 1) - } -} - -/// 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>, - /// The lines by split \n - pub(super) 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(), - lines: Vec::new(), - } - } - - pub(super) fn set_wrap_width(&mut self, wrap_width: Option, cx: &mut App) { - self.wrap_width = wrap_width; - self.update(&self.text.clone(), true, cx); - } - - pub(super) fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) { - self.font = font; - self.font_size = font_size; - self.update(&self.text.clone(), true, cx); - } - - pub(super) fn update(&mut self, text: &SharedString, force: bool, cx: &mut App) { - if &self.text == text && !force { - return; - } - - let mut wrapped_lines = vec![]; - let mut 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); - - let mut prev_line_ix = 0; - for line in text.split('\n') { - let mut line_wraps = vec![]; - let mut prev_boundary_ix = 0; - - // Here only have wrapped line, if there is no wrap meet, the `line_wraps` result will empty. - for boundary in line_wrapper.wrap_line(&[LineFragment::text(line)], wrap_width) { - line_wraps.push(prev_boundary_ix..boundary.ix); - prev_boundary_ix = boundary.ix; - } - - lines.push(LineWrap { - wrap_lines: line_wraps.len(), - range: prev_line_ix..prev_line_ix + line.len(), - }); - - wrapped_lines.extend(line_wraps); - // Reset of the line - if !line[prev_boundary_ix..].is_empty() || prev_boundary_ix == 0 { - wrapped_lines.push(prev_line_ix + prev_boundary_ix..prev_line_ix + line.len()); - } - - prev_line_ix += line.len() + 1; - } - - self.text = text.clone(); - self.wrapped_lines = wrapped_lines; - self.lines = lines; - } -} +use std::ops::Range; + +use gpui::{App, Font, LineFragment, Pixels}; +use rope::Rope; + +use super::rope_ext::RopeExt; + +/// A line with soft wrapped lines info. +#[derive(Clone)] +pub(super) struct LineItem { + /// The original line text. + line: Rope, + /// The soft wrapped lines relative byte range (0..line.len) of this line (Include first line). + /// + /// FIXME: Here in somecase, the `line_wrapper.wrap_line` has returned different + /// like the `window.text_system().shape_text`. So, this value may not equal + /// the actual rendered lines. + wrapped_lines: Vec>, +} + +impl LineItem { + /// Get the bytes length of this line. + #[inline] + pub(super) fn len(&self) -> usize { + self.line.len() + } + + /// Get number of soft wrapped lines of this line (include the first line). + #[inline] + pub(super) fn lines_len(&self) -> usize { + self.wrapped_lines.len() + } + + /// Get the height of this line item with given line height. + pub(super) fn height(&self, line_height: Pixels) -> Pixels { + self.lines_len() as f32 * line_height + } +} + +/// Used to prepare the text with soft wrap to be get lines to displayed in the Editor. +/// +/// After use lines to calculate the scroll size of the Editor. +pub(super) struct TextWrapper { + text: Rope, + /// Total wrapped lines (Inlucde the first line), value is start and end index of the line. + soft_lines: usize, + font: Font, + font_size: Pixels, + /// If is none, it means the text is not wrapped + wrap_width: Option, + /// The lines by split \n + pub(super) lines: Vec, +} + +#[allow(unused)] +impl TextWrapper { + pub(super) fn new(font: Font, font_size: Pixels, wrap_width: Option) -> Self { + Self { + text: Rope::new(), + font, + font_size, + wrap_width, + soft_lines: 0, + lines: Vec::new(), + } + } + + #[inline] + pub(super) fn set_default_text(&mut self, text: &Rope) { + self.text = text.clone(); + } + + /// Get the total number of lines including wrapped lines. + #[inline] + pub(super) fn len(&self) -> usize { + self.soft_lines + } + + /// Get the line item by row index. + #[inline] + pub(super) fn line(&self, row: usize) -> Option<&LineItem> { + self.lines.get(row) + } + + pub(super) fn set_wrap_width(&mut self, wrap_width: Option, cx: &mut App) { + if wrap_width == self.wrap_width { + return; + } + + self.wrap_width = wrap_width; + self.update_all(&self.text.clone(), true, cx); + } + + pub(super) fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) { + if self.font.eq(&font) && self.font_size == font_size { + return; + } + + self.font = font; + self.font_size = font_size; + self.update_all(&self.text.clone(), true, cx); + } + + /// Update the text wrapper and recalculate the wrapped lines. + /// + /// If the `text` is the same as the current text, do nothing. + /// + /// - `changed_text`: The text [`Rope`] that has changed. + /// - `range`: The `selected_range` before change. + /// - `new_text`: The inserted text. + /// - `force`: Whether to force the update, if false, the update will be skipped if the text is the same. + /// - `cx`: The application context. + pub(super) fn update( + &mut self, + changed_text: &Rope, + range: &Range, + new_text: &Rope, + force: bool, + cx: &mut App, + ) { + let mut line_wrapper = cx + .text_system() + .line_wrapper(self.font.clone(), self.font_size); + self._update( + changed_text, + range, + new_text, + force, + &mut |line_str, wrap_width| { + line_wrapper + .wrap_line(&[LineFragment::text(line_str)], wrap_width) + .collect() + }, + ); + } + + fn _update( + &mut self, + changed_text: &Rope, + range: &Range, + new_text: &Rope, + force: bool, + wrap_line: &mut F, + ) where + F: FnMut(&str, Pixels) -> Vec, + { + if self.text.eq(changed_text) && !force { + return; + } + + // Remove the old changed lines. + let start_row = self.text.offset_to_point(range.start).row as usize; + let start_row = start_row.min(self.lines.len().saturating_sub(1)); + let end_row = self.text.offset_to_point(range.end).row as usize; + let end_row = end_row.min(self.lines.len().saturating_sub(1)); + let rows_range = start_row..=end_row; + + // To add the new lines. + let new_start_row = changed_text.offset_to_point(range.start).row as usize; + let new_start_offset = changed_text.line_start_offset(new_start_row); + let new_end_row = changed_text + .offset_to_point(range.start + new_text.len()) + .row as usize; + let new_end_offset = changed_text.line_end_offset(new_end_row); + let new_range = new_start_offset..new_end_offset; + + let mut new_lines = vec![]; + + let wrap_width = self.wrap_width; + + for line in changed_text.slice(new_range).lines() { + let line_str = line.to_string(); + let mut wrapped_lines = vec![]; + let mut prev_boundary_ix = 0; + + // If wrap_width is Pixels::MAX, skip wrapping to disable word wrap + if let Some(wrap_width) = wrap_width { + // Here only have wrapped line, if there is no wrap meet, the `line_wraps` result will empty. + for boundary in wrap_line(&line_str, wrap_width) { + wrapped_lines.push(prev_boundary_ix..boundary.ix); + prev_boundary_ix = boundary.ix; + } + } + + // Reset of the line + if !line_str[prev_boundary_ix..].is_empty() || prev_boundary_ix == 0 { + wrapped_lines.push(prev_boundary_ix..line.len()); + } + + new_lines.push(LineItem { + line: line.clone(), + wrapped_lines, + }); + } + + // dbg!(&new_lines.len()); + // dbg!(self.lines.len()); + if self.lines.is_empty() { + self.lines = new_lines; + } else { + self.lines.splice(rows_range, new_lines); + } + + // dbg!(self.lines.len()); + self.text = changed_text.clone(); + self.soft_lines = self.lines.iter().map(|l| l.lines_len()).sum(); + } + + /// Update the text wrapper and recalculate the wrapped lines. + /// + /// If the `text` is the same as the current text, do nothing. + pub(crate) fn update_all(&mut self, text: &Rope, force: bool, cx: &mut App) { + self.update(text, &(0..text.len()), text, force, cx); + } +} diff --git a/crates/ui/src/list/list.rs b/crates/ui/src/list/list.rs index f796e282..51b272d9 100644 --- a/crates/ui/src/list/list.rs +++ b/crates/ui/src/list/list.rs @@ -299,14 +299,16 @@ where fn on_query_input_event( &mut self, - _: &Entity, + state: &Entity, event: &InputEvent, window: &mut Window, cx: &mut Context, ) { match event { - InputEvent::Change(text) => { + InputEvent::Change => { + let text = state.read(cx).value(); let text = text.trim().to_string(); + if Some(&text) == self.last_query.as_ref() { return; } @@ -347,7 +349,7 @@ where } } - fn set_querying(&mut self, querying: bool, _: &mut Window, cx: &mut Context) { + fn set_querying(&mut self, querying: bool, _window: &mut Window, cx: &mut Context) { self.querying = querying; if let Some(input) = &self.query_input { input.update(cx, |input, cx| input.set_loading(querying, cx))