diff --git a/Cargo.lock b/Cargo.lock index d8ad9f99..f5062a40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7072,12 +7072,6 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" -[[package]] -name = "streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" - [[package]] name = "strict-num" version = "0.1.1" @@ -7872,26 +7866,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "tree-sitter" -version = "0.26.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17ebdd3a5a7e28a1890b876fdbd0c3c0fe0a6336cffaa104f11b9f720c9daa29" -dependencies = [ - "cc", - "regex", - "regex-syntax", - "serde_json", - "streaming-iterator", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-language" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081" - [[package]] name = "try-lock" version = "0.2.5" @@ -7957,19 +7931,13 @@ dependencies = [ "common", "gpui-base", "gpui-pre", - "gpui-pre-sum-tree", "instant", "itertools 0.13.0", "log", - "lsp-types", - "regex", - "ropey", "serde", "smallvec", "smol", "theme", - "tree-sitter", - "unicode-segmentation", "uuid", ] diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index ecf0efd1..977aef6b 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -29,7 +29,7 @@ use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; -use ui::input::{Input, InputEvent, InputState}; +use ui::input::{Input, InputEvent, InputState, Textarea, TextareaState}; use ui::menu::DropdownMenu; use ui::notification::Notification; use ui::scroll::Scrollbar; @@ -85,7 +85,7 @@ pub struct ChatPanel { reports_by_id: Arc>>>, /// Chat input state - input: Entity, + input: Entity, /// Subject input state subject_input: Entity, @@ -142,7 +142,7 @@ impl ChatPanel { // Define input state let input = cx.new(|cx| { - InputState::new(window, cx) + TextareaState::new(window, cx) .placeholder(format!("Message {}", name)) .auto_grow(1, 20) .clean_on_escape() @@ -2108,7 +2108,7 @@ impl Render for ChatPanel { this.upload(window, cx); })), ) - .child(Input::new(&self.input).appearance(false).flex_1()) + .child(Textarea::new(&self.input).appearance(false).flex_1()) .child( h_flex() .pl_1() diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 2a612f33..7ce646ef 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -17,13 +17,7 @@ anyhow.workspace = true itertools.workspace = true log.workspace = true -unicode-segmentation = "1.12.0" uuid = "1.10" -regex = "1" -lsp-types = "0.97.0" -ropey = { version = "=2.0.0-beta.1", features = ["metric_lines_lf", "metric_utf16"] } -sum_tree.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] smol.workspace = true -tree-sitter = "0.26" diff --git a/crates/ui/src/history.rs b/crates/ui/src/history.rs deleted file mode 100644 index 56785f0e..00000000 --- a/crates/ui/src/history.rs +++ /dev/null @@ -1,184 +0,0 @@ -use std::fmt::Debug; -use instant::{Duration, Instant}; - -/// A HistoryItem represents a single change in the history. -/// It must implement Clone and PartialEq to be used in the History. -pub trait HistoryItem: Clone + PartialEq { - fn version(&self) -> usize; - fn set_version(&mut self, version: usize); -} - -/// The History is used to keep track of changes to a model and to allow undo and redo operations. -/// -/// This is now used in Input for undo/redo operations. You can also use this in -/// your own models to keep track of changes, for example to track the tab -/// history for prev/next features. -/// -/// ## Use cases -/// -/// - Undo/redo operations in Input -/// - Tracking tab history for prev/next features -#[derive(Debug)] -pub struct History { - undos: Vec, - redos: Vec, - last_changed_at: Instant, - version: usize, - pub(crate) ignore: bool, - max_undos: usize, - group_interval: Option, - grouping: bool, - unique: bool, -} - -impl History -where - I: HistoryItem, -{ - pub fn new() -> Self { - Self { - undos: Default::default(), - redos: Default::default(), - ignore: false, - last_changed_at: Instant::now(), - version: 0, - max_undos: 1000, - group_interval: None, - grouping: false, - unique: false, - } - } - - /// Set the maximum number of undo steps to keep, defaults to 1000. - pub fn max_undos(mut self, max_undos: usize) -> Self { - self.max_undos = max_undos; - self - } - - /// Set the history to be unique, defaults to false. - /// If set to true, the history will only keep unique changes. - pub fn unique(mut self) -> Self { - self.unique = true; - self - } - - /// Set the interval in milliseconds to group changes, defaults to None. - pub fn group_interval(mut self, group_interval: Duration) -> Self { - self.group_interval = Some(group_interval); - self - } - - /// Start grouping changes, this will prevent the version from being incremented until `end_grouping` is called. - pub fn start_grouping(&mut self) { - self.grouping = true; - } - - /// End grouping changes, this will allow the version to be incremented again. - pub fn end_grouping(&mut self) { - self.grouping = false; - } - - /// Increment the version number if the last change was made more than `GROUP_INTERVAL` milliseconds ago. - fn inc_version(&mut self) -> usize { - let t = Instant::now(); - if !self.grouping && Some(self.last_changed_at.elapsed()) > self.group_interval { - self.version += 1; - } - - self.last_changed_at = t; - self.version - } - - /// Get the current version number. - pub fn version(&self) -> usize { - self.version - } - - /// Push a new change to the history. - pub fn push(&mut self, item: I) { - let version = self.inc_version(); - - if self.undos.len() >= self.max_undos { - self.undos.remove(0); - } - - if self.unique { - self.undos.retain(|c| *c != item); - self.redos.retain(|c| *c != item); - } - - let mut item = item; - item.set_version(version); - self.undos.push(item); - } - - /// Get the undo stack. - pub fn undos(&self) -> &Vec { - &self.undos - } - - /// Get the redo stack. - pub fn redos(&self) -> &Vec { - &self.redos - } - - /// Clear the undo and redo stacks. - pub fn clear(&mut self) { - self.undos.clear(); - self.redos.clear(); - } - - /// Undo the last change and return the changes that were undone. - pub fn undo(&mut self) -> Option> { - if let Some(first_change) = self.undos.pop() { - let mut changes = vec![first_change.clone()]; - // pick the next all changes with the same version - while self - .undos - .iter() - .filter(|c| c.version() == first_change.version()) - .count() - > 0 - { - let change = self.undos.pop().unwrap(); - changes.push(change); - } - - self.redos.extend(changes.clone()); - Some(changes) - } else { - None - } - } - - /// Redo the last undone change and return the changes that were redone. - pub fn redo(&mut self) -> Option> { - if let Some(first_change) = self.redos.pop() { - let mut changes = vec![first_change.clone()]; - // pick the next all changes with the same version - while self - .redos - .iter() - .filter(|c| c.version() == first_change.version()) - .count() - > 0 - { - let change = self.redos.pop().unwrap(); - changes.push(change); - } - self.undos.extend(changes.clone()); - Some(changes) - } else { - None - } - } -} - -impl Default for History -where - I: HistoryItem, -{ - fn default() -> Self { - Self::new() - } -} diff --git a/crates/ui/src/input/blink_cursor.rs b/crates/ui/src/input/blink_cursor.rs deleted file mode 100644 index a0c2c979..00000000 --- a/crates/ui/src/input/blink_cursor.rs +++ /dev/null @@ -1,96 +0,0 @@ -use instant::Duration; - -use gpui::{Context, Pixels, Task, px}; - -static INTERVAL: Duration = Duration::from_millis(500); -static PAUSE_DELAY: Duration = Duration::from_millis(300); - -// On Windows, Linux, we should use integer to avoid blurry cursor. -#[cfg(not(target_os = "macos"))] -pub(super) const CURSOR_WIDTH: Pixels = px(2.); -#[cfg(target_os = "macos")] -pub(super) const CURSOR_WIDTH: Pixels = px(1.5); - -/// To manage the Input cursor blinking. -/// -/// It will start blinking with a interval of 500ms. -/// 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 { - visible: bool, - paused: bool, - epoch: usize, - - _task: Task<()>, -} - -impl BlinkCursor { - pub fn new() -> Self { - Self { - visible: false, - paused: false, - epoch: 0, - _task: Task::ready(()), - } - } - - /// Start the blinking - pub fn start(&mut self, cx: &mut Context) { - self.blink(self.epoch, cx); - } - - pub fn stop(&mut self, cx: &mut Context) { - self.epoch = 0; - cx.notify(); - } - - fn next_epoch(&mut self) -> usize { - self.epoch += 1; - self.epoch - } - - fn blink(&mut self, epoch: usize, cx: &mut Context) { - if self.paused || epoch != self.epoch { - self.visible = true; - return; - } - - self.visible = !self.visible; - cx.notify(); - - // Schedule the next blink - let epoch = self.next_epoch(); - self._task = cx.spawn(async move |this, cx| { - cx.background_executor().timer(INTERVAL).await; - if let Some(this) = this.upgrade() { - this.update(cx, |this, cx| this.blink(epoch, cx)); - } - }); - } - - pub fn visible(&self) -> bool { - // Keep showing the cursor if paused - self.paused || self.visible - } - - /// 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(); - self._task = cx.spawn(async move |this, cx| { - cx.background_executor().timer(PAUSE_DELAY).await; - - if let Some(this) = this.upgrade() { - this.update(cx, |this, cx| { - this.paused = false; - this.blink(epoch, cx); - }); - } - }); - } -} diff --git a/crates/ui/src/input/change.rs b/crates/ui/src/input/change.rs deleted file mode 100644 index bd771a9a..00000000 --- a/crates/ui/src/input/change.rs +++ /dev/null @@ -1,39 +0,0 @@ -use std::fmt::Debug; - -use crate::{history::HistoryItem, input::Selection}; - -#[derive(Debug, PartialEq, Clone)] -pub struct Change { - pub(crate) old_range: Selection, - pub(crate) old_text: String, - pub(crate) new_range: Selection, - pub(crate) new_text: String, - version: usize, -} - -impl Change { - pub fn new( - old_range: impl Into, - old_text: &str, - new_range: impl Into, - new_text: &str, - ) -> Self { - Self { - old_range: old_range.into(), - old_text: old_text.to_string(), - new_range: new_range.into(), - new_text: new_text.to_string(), - version: 0, - } - } -} - -impl HistoryItem for Change { - fn version(&self) -> usize { - self.version - } - - fn set_version(&mut self, version: usize) { - self.version = version; - } -} diff --git a/crates/ui/src/input/cursor.rs b/crates/ui/src/input/cursor.rs deleted file mode 100644 index e25a7df8..00000000 --- a/crates/ui/src/input/cursor.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::ops::{Range, RangeBounds}; - -/// 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 - } -} -impl RangeBounds for Selection { - fn start_bound(&self) -> std::ops::Bound<&usize> { - std::ops::Bound::Included(&self.start) - } - - fn end_bound(&self) -> std::ops::Bound<&usize> { - std::ops::Bound::Excluded(&self.end) - } -} diff --git a/crates/ui/src/input/display_map/display_map.rs b/crates/ui/src/input/display_map/display_map.rs deleted file mode 100644 index affdfa30..00000000 --- a/crates/ui/src/input/display_map/display_map.rs +++ /dev/null @@ -1,172 +0,0 @@ -use std::ops::Range; - -use gpui::{App, Font, Pixels}; -use ropey::Rope; - -use super::text_wrapper::{LineItem, WrapDisplayPoint}; -use super::wrap_map::WrapMap; -use crate::input::Point as TreeSitterPoint; - -/// DisplayMap is the main interface for Input coordinate mapping. -pub struct DisplayMap { - wrap_map: WrapMap, -} - -impl DisplayMap { - pub fn new(font: Font, font_size: Pixels, wrap_width: Option) -> Self { - Self { - wrap_map: WrapMap::new(font, font_size, wrap_width), - } - } - - /// Get total number of display rows (same as wrap rows without folding) - #[inline] - pub fn display_row_count(&self) -> usize { - self.wrap_map.wrap_row_count() - } - - /// Get the buffer line for a given display row - pub fn display_row_to_buffer_line(&self, display_row: usize) -> usize { - self.wrap_map.wrap_row_to_buffer_line(display_row) - } - - /// Get the display row range for a buffer line: [start, end) - pub fn buffer_line_to_display_row_range(&self, line: usize) -> Option> { - let range = self.wrap_map.buffer_line_to_wrap_row_range(line); - if range.is_empty() { None } else { Some(range) } - } - - /// Check if a buffer line is completely hidden (never true without folding) - #[inline] - pub fn is_buffer_line_hidden(&self, _line: usize) -> bool { - false - } - - /// All wrap rows are visible since there's no folding. - #[inline] - pub fn folded_ranges(&self) -> &[()] { - &[] - } - - /// Adjust folds for edit (no-op without folding) - pub fn adjust_folds_for_edit( - &mut self, - _old_text: &Rope, - _range: &Range, - _new_text: &str, - ) { - // No-op: no folding - } - - /// Update text (incremental or full) - pub fn on_text_changed( - &mut self, - changed_text: &Rope, - range: &Range, - new_text: &Rope, - cx: &mut App, - ) { - self.wrap_map - .on_text_changed(changed_text, range, new_text, cx); - } - - /// Update layout parameters (wrap width or font) - pub fn on_layout_changed(&mut self, wrap_width: Option, cx: &mut App) { - self.wrap_map.on_layout_changed(wrap_width, cx); - } - - /// Set font parameters - pub fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) { - self.wrap_map.set_font(font, font_size, cx); - } - - /// Ensure text is prepared (initializes wrapper if needed) - pub fn ensure_text_prepared(&mut self, text: &Rope, cx: &mut App) { - self.wrap_map.ensure_text_prepared(text, cx); - } - - /// Initialize with text - pub fn set_text(&mut self, text: &Rope, cx: &mut App) { - self.wrap_map.set_text(text, cx); - } - - /// Convert byte offset to wrap display point (with soft wrap info). - #[inline] - pub(crate) fn offset_to_wrap_display_point(&self, offset: usize) -> WrapDisplayPoint { - self.wrap_map.wrapper().offset_to_display_point(offset) - } - - /// Convert wrap display point to byte offset. - #[inline] - pub(crate) fn wrap_display_point_to_offset(&self, point: WrapDisplayPoint) -> usize { - self.wrap_map.wrapper().display_point_to_offset(point) - } - - /// Convert wrap display point to TreeSitterPoint (buffer line/col). - #[inline] - pub(crate) fn wrap_display_point_to_point(&self, point: WrapDisplayPoint) -> TreeSitterPoint { - self.wrap_map.wrapper().display_point_to_point(point) - } - - /// Since there's no folding, wrap row == display row. - #[inline] - pub fn wrap_row_to_display_row(&self, wrap_row: usize) -> Option { - if wrap_row < self.wrap_row_count() { - Some(wrap_row) - } else { - None - } - } - - /// Since there's no folding, nearest visible row is the row itself. - #[inline] - pub fn nearest_visible_display_row(&self, wrap_row: usize) -> usize { - wrap_row.min(self.wrap_row_count().saturating_sub(1)) - } - - /// Since there's no folding, display row == wrap row. - #[inline] - pub fn display_row_to_wrap_row(&self, display_row: usize) -> Option { - if display_row < self.wrap_row_count() { - Some(display_row) - } else { - None - } - } - - /// Get the longest row index (by byte length). - #[inline] - pub(crate) fn longest_row(&self) -> usize { - self.wrap_map.wrapper().longest_row.row - } - - /// Get access to line items (for rendering) - #[inline] - pub(crate) fn lines(&self) -> &[LineItem] { - self.wrap_map.lines() - } - - /// Get the rope text - #[inline] - pub fn text(&self) -> &Rope { - self.wrap_map.text() - } - - /// Calculate how many wrap rows of a buffer line are visible - #[inline] - pub fn visible_wrap_row_count_for_buffer_line(&self, line: usize) -> usize { - self.wrap_map.visible_wrap_row_count_for_buffer_line(line) - } - - /// Get the wrap row count - #[inline] - pub fn wrap_row_count(&self) -> usize { - self.wrap_map.wrap_row_count() - } - - /// Get the buffer line count (logical lines) - #[inline] - pub fn buffer_line_count(&self) -> usize { - self.wrap_map.buffer_line_count() - } -} diff --git a/crates/ui/src/input/display_map/mod.rs b/crates/ui/src/input/display_map/mod.rs deleted file mode 100644 index 486b3e59..00000000 --- a/crates/ui/src/input/display_map/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -#[allow(clippy::module_inception)] -mod display_map; -mod text_wrapper; -mod wrap_map; - -pub use self::display_map::DisplayMap; -pub(crate) use self::text_wrapper::LineLayout; diff --git a/crates/ui/src/input/display_map/text_wrapper.rs b/crates/ui/src/input/display_map/text_wrapper.rs deleted file mode 100644 index dadb1336..00000000 --- a/crates/ui/src/input/display_map/text_wrapper.rs +++ /dev/null @@ -1,582 +0,0 @@ -use std::ops::Range; - -use gpui::{ - App, Font, Half, LineFragment, Pixels, Point, ShapedLine, Size, TextAlign, Window, point, px, - size, -}; -use ropey::Rope; -use smallvec::SmallVec; - -use crate::input::{LastLayout, Point as TreeSitterPoint, RopeExt, WhitespaceIndicators}; - -/// A line with soft wrapped lines info. -#[derive(Debug, Clone)] -pub(crate) struct LineItem { - /// The original line text, without end `\n`. - line: Rope, - /// The soft wrapped lines relative byte range (0..line.len) of this line (Include first line). - /// - /// Not contains the line end `\n`. - pub(crate) wrapped_lines: Vec>, -} - -impl LineItem { - /// Get the bytes length of this line. - #[inline] - pub(crate) fn len(&self) -> usize { - self.line.len() - } - - /// Get number of soft wrapped lines of this line (include the first line). - #[inline] - pub(crate) fn lines_len(&self) -> usize { - self.wrapped_lines.len() - } -} - -#[derive(Debug, Default)] -pub(crate) struct LongestRow { - /// The 0-based row index. - pub row: usize, - /// The bytes length of the longest line. - pub len: usize, -} - -/// 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(crate) 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 longest (row, bytes len) in characters, used to calculate the horizontal scroll width. - pub(crate) longest_row: LongestRow, - /// The lines by split \n - pub(crate) lines: Vec, - - _initialized: bool, -} - -#[allow(unused)] -impl TextWrapper { - pub(crate) fn new(font: Font, font_size: Pixels, wrap_width: Option) -> Self { - Self { - text: Rope::new(), - font, - font_size, - wrap_width, - soft_lines: 0, - longest_row: LongestRow::default(), - lines: Vec::new(), - _initialized: false, - } - } - - #[inline] - pub(crate) fn set_default_text(&mut self, text: &Rope) { - self.text = text.clone(); - } - - /// Get reference to the rope text. - #[inline] - pub(crate) fn text(&self) -> &Rope { - &self.text - } - - /// Get the total number of lines including wrapped lines. - #[inline] - pub(crate) fn len(&self) -> usize { - self.soft_lines - } - - /// Get the line item by row index. - #[inline] - pub(crate) fn line(&self, row: usize) -> Option<&LineItem> { - self.lines.get(row) - } - - pub(crate) 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(), cx); - } - - pub(crate) 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(), cx); - } - - pub(crate) fn prepare_if_need(&mut self, text: &Rope, cx: &mut App) -> bool { - if self._initialized { - return false; - } - self._initialized = true; - self.update_all(text, cx); - true - } - - /// 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(crate) fn update( - &mut self, - changed_text: &Rope, - range: &Range, - new_text: &Rope, - 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, - &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, - wrap_line: &mut F, - ) where - F: FnMut(&str, Pixels) -> Vec, - { - // Remove the old changed lines. - let start_row = self.text.offset_to_point(range.start).row; - let start_row = start_row.min(self.lines.len().saturating_sub(1)); - let end_row = self.text.offset_to_point(range.end).row; - let end_row = end_row.min(self.lines.len().saturating_sub(1)); - let rows_range = start_row..=end_row; - - if rows_range.contains(&self.longest_row.row) { - self.longest_row = LongestRow::default(); - } - - let mut longest_row_ix = self.longest_row.row; - let mut longest_row_len = self.longest_row.len; - - // To add the new lines. - let new_start_row = changed_text.offset_to_point(range.start).row; - 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; - 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; - - // line not contains `\n`. - for (ix, line) in Rope::from(changed_text.slice(new_range)) - .iter_lines() - .enumerate() - { - let line_str = line.to_string(); - let mut wrapped_lines = vec![]; - let mut prev_boundary_ix = 0; - - if line_str.len() > longest_row_len { - longest_row_ix = new_start_row + ix; - longest_row_len = line_str.len(); - } - - // 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: Rope::from(line), - wrapped_lines, - }); - } - - if self.lines.is_empty() { - self.lines = new_lines; - } else { - self.lines.splice(rows_range, new_lines); - } - - self.text = changed_text.clone(); - self.soft_lines = self.lines.iter().map(|l| l.lines_len()).sum(); - self.longest_row = LongestRow { - row: longest_row_ix, - len: longest_row_len, - } - } - - /// Update the text wrapper and recalculate the wrapped lines. - /// - /// If the `text` is the same as the current text, do nothing. - fn update_all(&mut self, text: &Rope, cx: &mut App) { - self.update(text, &(0..text.len()), text, cx); - } - - /// Return display point (with soft wrap) from the given byte offset in the text. - /// - /// Panics if the `offset` is out of bounds. - pub(crate) fn offset_to_display_point(&self, offset: usize) -> WrapDisplayPoint { - let row = self.text.offset_to_point(offset).row; - let start = self.text.line_start_offset(row); - let line = &self.lines[row]; - - let mut wrapped_row = self - .lines - .iter() - .take(row) - .map(|l| l.lines_len()) - .sum::(); - - let local_offset = offset.saturating_sub(start); - for (ix, range) in line.wrapped_lines.iter().enumerate() { - if range.contains(&local_offset) { - return WrapDisplayPoint::new( - wrapped_row + ix, - ix, - local_offset.saturating_sub(range.start), - ); - } - } - - // Otherwise return the eof of the line. - let last_range = line.wrapped_lines.last().unwrap_or(&(0..0)); - let ix = line.lines_len().saturating_sub(1); - - WrapDisplayPoint::new(wrapped_row + ix, ix, last_range.len()) - } - - /// Return byte offset in the text from the given display point (with soft wrap). - /// - /// Panics if the `point.row` is out of bounds. - pub(crate) fn display_point_to_offset(&self, point: WrapDisplayPoint) -> usize { - let mut wrapped_row = 0; - for (row, line) in self.lines.iter().enumerate() { - if wrapped_row + line.lines_len() > point.row { - let line_start = self.text.line_start_offset(row); - let local_row = point.row.saturating_sub(wrapped_row); - if let Some(range) = line.wrapped_lines.get(local_row) { - return line_start + (range.start + point.column).min(range.end); - } else { - // If not found, return the end of the line. - return line_start + line.len(); - } - } - - wrapped_row += line.lines_len(); - } - - self.text.len() - } - - pub(crate) fn display_point_to_point(&self, point: WrapDisplayPoint) -> TreeSitterPoint { - let offset = self.display_point_to_offset(point); - self.text.offset_to_point(offset) - } - - pub(crate) fn point_to_display_point(&self, point: TreeSitterPoint) -> WrapDisplayPoint { - let offset = self.text.point_to_offset(point); - self.offset_to_display_point(offset) - } -} - -/// A display point within the soft-wrapped text. -/// -/// This represents a position in the text after soft-wrapping, -/// with an additional `local_row` field tracking the wrap line -/// within the original buffer line. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct WrapDisplayPoint { - /// The 0-based soft wrapped row index in the text. - pub row: usize, - /// The 0-based row index in local line (include first line). - /// - /// This value only valid when return from [`TextWrapper::offset_to_display_point`], otherwise it will be ignored. - pub local_row: usize, - /// The 0-based column byte index in the display line (with soft wrap). - pub column: usize, -} - -impl WrapDisplayPoint { - pub fn new(row: usize, local_row: usize, column: usize) -> Self { - Self { - row, - local_row, - column, - } - } -} - -/// The layout info of a line with soft wrapped lines. -pub(crate) struct LineLayout { - /// Total bytes length of this line. - len: usize, - /// The soft wrapped lines of this line (Include the first line). - pub(crate) wrapped_lines: SmallVec<[ShapedLine; 1]>, - pub(crate) longest_width: Pixels, - pub(crate) whitespace_indicators: Option, - /// Whitespace indicators: (line_index, x_position, is_tab) - pub(crate) whitespace_chars: Vec<(usize, Pixels, bool)>, -} - -impl LineLayout { - pub(crate) fn new() -> Self { - Self { - len: 0, - longest_width: px(0.), - wrapped_lines: SmallVec::new(), - whitespace_chars: Vec::new(), - whitespace_indicators: None, - } - } - - pub(crate) fn lines(mut self, wrapped_lines: SmallVec<[ShapedLine; 1]>) -> Self { - self.set_wrapped_lines(wrapped_lines); - self - } - - pub(crate) fn set_wrapped_lines(&mut self, wrapped_lines: SmallVec<[ShapedLine; 1]>) { - self.len = wrapped_lines.iter().map(|l| l.len).sum(); - let width = wrapped_lines - .iter() - .map(|l| l.width) - .max() - .unwrap_or_default(); - self.longest_width = width; - self.wrapped_lines = wrapped_lines; - } - - pub(crate) fn with_whitespaces(mut self, indicators: Option) -> Self { - self.whitespace_indicators = indicators; - let Some(indicators) = self.whitespace_indicators.as_ref() else { - return self; - }; - - let space_indicator_offset = indicators.space.width.half(); - - for (line_index, wrapped_line) in self.wrapped_lines.iter().enumerate() { - for (relative_offset, c) in wrapped_line.text.char_indices() { - if matches!(c, ' ' | '\t') { - let is_tab = c == '\t'; - let start_x = wrapped_line.x_for_index(relative_offset); - let end_x = wrapped_line.x_for_index(relative_offset + c.len_utf8()); - // Center the indicator in the actual character's space - let x_position = if c == ' ' { - (start_x + end_x).half() - space_indicator_offset - } else { - start_x - }; - - self.whitespace_chars.push((line_index, x_position, is_tab)); - } - } - } - self - } - - #[inline] - pub(crate) fn len(&self) -> usize { - self.len - } - - /// Get the position (x, y) for the given index in this line layout. - /// - /// - The `offset` is a local byte index in this line layout. - /// - When `line_end_affinity` is true, an offset at a soft wrap boundary is placed at - /// the end of the current visual line rather than the start of the next one. - /// - The return value is relative to the top-left corner of this line layout, start from (0, 0) - pub(crate) fn position_for_index( - &self, - offset: usize, - last_layout: &LastLayout, - line_end_affinity: bool, - ) -> Option> { - let mut acc_len = 0; - let mut offset_y = px(0.); - - let x_offset = last_layout.alignment_offset(self.longest_width); - - for (i, line) in self.wrapped_lines.iter().enumerate() { - let is_last = i + 1 == self.wrapped_lines.len(); - - let matches = if line.len == 0 { - // Empty visual lines still own their boundary offset. - offset == acc_len - } else if is_last || line_end_affinity { - // Inclusive: cursor can sit at end of this visual line. - offset >= acc_len && offset <= acc_len + line.len - } else { - // Exclusive: boundary offset belongs to the next visual line. - offset >= acc_len && offset < acc_len + line.len - }; - - if matches { - let x = line.x_for_index(offset.saturating_sub(acc_len)) + x_offset; - return Some(point(x, offset_y)); - } - - // Always advance by actual line length. The last line gets +1 so the - // cursor can be placed after the final character. - acc_len += if is_last { line.len + 1 } else { line.len }; - offset_y += last_layout.line_height; - } - - None - } - - /// Get the closest index for the given x in this line layout. - pub(crate) fn closest_index_for_x(&self, x: Pixels, last_layout: &LastLayout) -> usize { - let mut acc_len = 0; - let x_offset = last_layout.alignment_offset(self.longest_width); - let x = x - x_offset; - - for (i, line) in self.wrapped_lines.iter().enumerate() { - let is_last = i + 1 == self.wrapped_lines.len(); - if x <= line.width { - let mut ix = line.closest_index_for_x(x); - if !is_last && ix == line.text.len() { - // For soft wrap line, we can't put the cursor at the end of the line. - let c_len = line.text.chars().last().map(|c| c.len_utf8()).unwrap_or(0); - ix = ix.saturating_sub(c_len); - } - - return acc_len + ix; - } - acc_len += line.text.len(); - } - - acc_len - } - - /// Get the index for the given position (x, y) in this line layout. - /// - /// The `pos` is relative to the top-left corner of this line layout, start from (0, 0) - /// The return value is a local byte index in this line layout, start from 0. - pub(crate) fn closest_index_for_position( - &self, - pos: Point, - last_layout: &LastLayout, - ) -> Option { - let mut offset = 0; - let mut line_top = px(0.); - let x_offset = last_layout.alignment_offset(self.longest_width); - for (i, line) in self.wrapped_lines.iter().enumerate() { - let is_last = i + 1 == self.wrapped_lines.len(); - let line_bottom = line_top + last_layout.line_height; - if pos.y >= line_top && pos.y < line_bottom { - let mut ix = line.closest_index_for_x(pos.x - x_offset); - if !is_last && ix == line.text.len() { - // For soft wrap line, we can't put the cursor at the end of the line. - let c_len = line.text.chars().last().map(|c| c.len_utf8()).unwrap_or(0); - ix = ix.saturating_sub(c_len); - } - return Some(offset + ix); - } - - offset += line.text.len(); - line_top = line_bottom; - } - - None - } - - pub(crate) fn index_for_position( - &self, - pos: Point, - last_layout: &LastLayout, - ) -> Option { - let mut offset = 0; - let mut line_top = px(0.); - let x_offset = last_layout.alignment_offset(self.longest_width); - for line in self.wrapped_lines.iter() { - let line_bottom = line_top + last_layout.line_height; - if pos.y >= line_top && pos.y < line_bottom { - let ix = line.index_for_x(pos.x - x_offset)?; - return Some(offset + ix); - } - - offset += line.text.len(); - line_top = line_bottom; - } - - None - } - - pub(crate) fn size(&self, line_height: Pixels) -> Size { - size(self.longest_width, self.wrapped_lines.len() * line_height) - } - - pub(crate) fn paint( - &self, - pos: Point, - line_height: Pixels, - text_align: TextAlign, - align_width: Option, - window: &mut Window, - cx: &mut App, - ) { - for (ix, line) in self.wrapped_lines.iter().enumerate() { - _ = line.paint( - pos + point(px(0.), ix * line_height), - line_height, - text_align, - align_width, - window, - cx, - ); - } - - // Paint whitespace indicators - if let Some(indicators) = self.whitespace_indicators.as_ref() { - for (line_index, x_position, is_tab) in &self.whitespace_chars { - let invisible = if *is_tab { - indicators.tab.clone() - } else { - indicators.space.clone() - }; - - let origin = point( - pos.x + *x_position, - pos.y + *line_index as f32 * line_height, - ); - - _ = invisible.paint(origin, line_height, text_align, align_width, window, cx); - } - } - } -} diff --git a/crates/ui/src/input/display_map/wrap_map.rs b/crates/ui/src/input/display_map/wrap_map.rs deleted file mode 100644 index ac422fc3..00000000 --- a/crates/ui/src/input/display_map/wrap_map.rs +++ /dev/null @@ -1,172 +0,0 @@ -/// WrapMap: Soft-wrapping layer (Buffer → Wrap rows). -/// -/// This module wraps the existing TextWrapper and provides: -/// - BufferPoint ↔ WrapPoint mapping -/// - Efficient buffer_line → wrap_row queries via prefix sum cache -/// - Incremental updates when text or layout changes -use std::ops::Range; - -use gpui::{App, Font, Pixels}; -use ropey::Rope; - -use super::text_wrapper::{LineItem, TextWrapper}; - -/// WrapMap manages soft-wrapping and provides buffer ↔ wrap coordinate mapping. -pub struct WrapMap { - /// The underlying text wrapper (reuses existing implementation) - wrapper: TextWrapper, - - /// Prefix sum cache: buffer_line_starts[line] = first wrap_row for buffer line `line` - /// This allows O(1) lookup of buffer_line → wrap_row - buffer_line_starts: Vec, - - /// Cached line count from last rebuild - cached_line_count: usize, - - /// Cached total wrap row count from last rebuild. - /// Used together with `cached_line_count` to detect if the cache is stale. - /// When soft wrap changes a line's wrap count without changing buffer line count, - /// this catches the staleness. - cached_wrap_row_count: usize, -} - -impl WrapMap { - pub fn new(font: Font, font_size: Pixels, wrap_width: Option) -> Self { - Self { - wrapper: TextWrapper::new(font, font_size, wrap_width), - buffer_line_starts: Vec::new(), - cached_line_count: 0, - cached_wrap_row_count: 0, - } - } - - /// Get total number of wrap rows (visual rows after soft-wrapping) - #[inline] - pub fn wrap_row_count(&self) -> usize { - self.wrapper.len() - } - - /// Get total number of buffer lines (logical lines) - #[inline] - pub fn buffer_line_count(&self) -> usize { - self.wrapper.lines.len() - } - - /// Get the buffer line for a given wrap row - pub fn wrap_row_to_buffer_line(&self, wrap_row: usize) -> usize { - if wrap_row >= self.wrap_row_count() { - return self.buffer_line_count().saturating_sub(1); - } - - // Binary search in prefix sum cache - match self.buffer_line_starts.binary_search(&wrap_row) { - Ok(line) => line, - Err(insert_pos) => insert_pos.saturating_sub(1), - } - } - - /// Get the first wrap row for a given buffer line - pub fn buffer_line_to_first_wrap_row(&self, line: usize) -> usize { - if line >= self.buffer_line_starts.len() { - return self.wrap_row_count(); - } - self.buffer_line_starts[line] - } - - /// Get the wrap row range for a buffer line: [start, end) - pub fn buffer_line_to_wrap_row_range(&self, line: usize) -> Range { - let start = self.buffer_line_to_first_wrap_row(line); - let end = if line + 1 < self.buffer_line_starts.len() { - self.buffer_line_starts[line + 1] - } else { - self.wrap_row_count() - }; - start..end - } - - /// Update text (incremental or full) - pub fn on_text_changed( - &mut self, - changed_text: &Rope, - range: &Range, - new_text: &Rope, - cx: &mut App, - ) { - self.wrapper.update(changed_text, range, new_text, cx); - self.rebuild_cache(); - } - - /// Update layout parameters (wrap width or font) - pub fn on_layout_changed(&mut self, wrap_width: Option, cx: &mut App) { - self.wrapper.set_wrap_width(wrap_width, cx); - self.rebuild_cache(); - } - - /// Set font parameters - pub fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) { - self.wrapper.set_font(font, font_size, cx); - self.rebuild_cache(); - } - - /// Ensure text is prepared (initializes wrapper if needed) - pub fn ensure_text_prepared(&mut self, text: &Rope, cx: &mut App) -> bool { - let did_initialize = self.wrapper.prepare_if_need(text, cx); - if did_initialize { - self.rebuild_cache(); - } - did_initialize - } - - /// Initialize with text - pub fn set_text(&mut self, text: &Rope, cx: &mut App) { - self.wrapper.set_default_text(text); - self.wrapper.prepare_if_need(text, cx); - self.rebuild_cache(); - } - - /// Rebuild the prefix sum cache: buffer_line_starts - fn rebuild_cache(&mut self) { - let line_count = self.wrapper.lines.len(); - let wrap_row_count = self.wrapper.len(); - - // Skip if nothing changed: both buffer line count and total wrap row count must match. - if line_count == self.cached_line_count - && wrap_row_count == self.cached_wrap_row_count - && !self.buffer_line_starts.is_empty() - { - return; - } - - self.buffer_line_starts.clear(); - - let mut wrap_row = 0; - for line_item in &self.wrapper.lines { - self.buffer_line_starts.push(wrap_row); - wrap_row += line_item.lines_len(); - } - - self.cached_line_count = line_count; - self.cached_wrap_row_count = wrap_row_count; - } - - /// Get access to the underlying wrapper (for rendering/hit-testing) - pub(crate) fn wrapper(&self) -> &TextWrapper { - &self.wrapper - } - - /// Get access to line items (for rendering) - pub(crate) fn lines(&self) -> &[LineItem] { - &self.wrapper.lines - } - - /// Get the rope text - pub fn text(&self) -> &Rope { - self.wrapper.text() - } - - /// Calculate how many wrap rows of a buffer line are visible. - /// Without folding, all wrap rows are visible. - pub fn visible_wrap_row_count_for_buffer_line(&self, line: usize) -> usize { - self.buffer_line_to_wrap_row_range(line).len() - } -} diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs deleted file mode 100644 index a19c27f2..00000000 --- a/crates/ui/src/input/element.rs +++ /dev/null @@ -1,1642 +0,0 @@ -use std::ops::Range; -use std::rc::Rc; - -use gpui::{ - AnyElement, App, Bounds, Corners, Edges, Element, ElementId, ElementInputHandler, Entity, - GlobalElementId, Half, Hsla, IntoElement, LayoutId, MouseButton, MouseMoveEvent, MouseUpEvent, - Path, Pixels, Point, Position, SharedString, Size, Style, TextAlign, TextRun, TextStyle, - UnderlineStyle, Window, fill, point, px, relative, size, -}; -use ropey::Rope; -use smallvec::SmallVec; -use theme::ActiveTheme; - -use super::mode::InputMode; -use super::{InputState, LastLayout, WhitespaceIndicators}; -use crate::Root; -use crate::input::RopeExt as _; -use crate::input::blink_cursor::CURSOR_WIDTH; -use crate::input::display_map::LineLayout; -use crate::scroll::Scrollbar; - -const BOTTOM_MARGIN_ROWS: usize = 3; -pub(super) const RIGHT_MARGIN: Pixels = px(10.); - -#[derive(Clone, Copy, Debug, PartialEq)] -struct EditorScrollbarLayout { - bounds: Bounds, - scroll_size: Size, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub(super) struct EditorScrollbarSnapshot { - layout: EditorScrollbarLayout, - cursor_scroll_offset: Point, - soft_wrap: bool, -} - -impl EditorScrollbarSnapshot { - fn new( - input_bounds: Bounds, - last_layout: &LastLayout, - scroll_size: Size, - cursor_scroll_offset: Point, - state: &InputState, - ) -> Self { - Self { - layout: EditorScrollbarLayout::new( - input_bounds, - last_layout.line_number_width, - scroll_size, - state.editor_scrollbar_paddings.get(), - ), - cursor_scroll_offset, - soft_wrap: state.soft_wrap, - } - } -} - -impl EditorScrollbarLayout { - fn new( - input_bounds: Bounds, - line_number_width: Pixels, - scroll_size: Size, - paddings: Edges, - ) -> Self { - let left = if line_number_width == px(0.) { - px(0.) - } else { - paddings.left + line_number_width - }; - - Self { - bounds: Bounds::new( - point( - input_bounds.origin.x + left, - input_bounds.origin.y - paddings.top, - ), - size( - input_bounds.size.width - left + paddings.right, - input_bounds.size.height + paddings.top + paddings.bottom, - ), - ), - scroll_size: size( - scroll_size.width - left + paddings.right + RIGHT_MARGIN, - scroll_size.height, - ), - } - } -} - -pub(super) struct EditorScrollbar { - state: Entity, -} - -impl EditorScrollbar { - pub(super) fn new(state: Entity) -> Self { - Self { state } - } -} - -impl IntoElement for EditorScrollbar { - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } -} - -impl Element for EditorScrollbar { - type PrepaintState = Option; - type RequestLayoutState = (); - - fn id(&self) -> Option { - Some("editor-scrollbar".into()) - } - - fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { - None - } - - fn request_layout( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - let style = Style { - position: Position::Absolute, - size: Size { - width: relative(1.).into(), - height: relative(1.).into(), - }, - ..Default::default() - }; - (window.request_layout(style, [], cx), ()) - } - - fn prepaint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - _: Bounds, - _: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Self::PrepaintState { - let state = self.state.read(cx); - let snapshot = state.editor_scrollbar_snapshot.get()?; - let scroll_handle = state.scroll_handle.clone(); - - if scroll_handle.offset() != snapshot.cursor_scroll_offset { - scroll_handle.set_offset(snapshot.cursor_scroll_offset); - } - - let mut scrollbar = if !snapshot.soft_wrap { - Scrollbar::new(&scroll_handle) - } else { - Scrollbar::vertical(&scroll_handle) - } - .scroll_size(snapshot.layout.scroll_size) - .into_any_element(); - - scrollbar.prepaint_as_root( - snapshot.layout.bounds.origin, - snapshot.layout.bounds.size.into(), - window, - cx, - ); - Some(scrollbar) - } - - fn paint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - _: Bounds, - _: &mut Self::RequestLayoutState, - prepaint: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - if let Some(scrollbar) = prepaint.as_mut() { - scrollbar.paint(window, cx); - } - } -} - -fn clamp_auto_grow_vertical_scroll_offset( - mode: &InputMode, - scroll_top: Pixels, - scroll_height: Pixels, - input_height: Pixels, -) -> Pixels { - if mode.is_auto_grow() { - scroll_top.clamp((input_height - scroll_height).min(px(0.)), px(0.)) - } else { - scroll_top - } -} - -use super::MASK_CHAR; - -/// Convert a byte offset in the original text to a byte offset in the masked display string. -/// -/// The masked string consists of `MASK_CHAR` repeated once per character in the original text. -/// Since `MASK_CHAR` may be multi-byte in UTF-8, the byte offset in the masked string is -/// `char_index * MASK_CHAR.len_utf8()`. -fn masked_display_offset(text: &Rope, original_offset: usize) -> usize { - text.offset_to_char_index(original_offset) * MASK_CHAR.len_utf8() -} - -pub(super) struct TextElement { - pub(crate) state: Entity, - placeholder: SharedString, -} - -impl TextElement { - pub(super) fn new(state: Entity) -> Self { - Self { - state, - placeholder: SharedString::default(), - } - } - - /// Set the placeholder text of the input field. - pub fn placeholder(mut self, placeholder: impl Into) -> Self { - self.placeholder = placeholder.into(); - self - } - - fn paint_mouse_listeners(&mut self, window: &mut Window, _: &mut App) { - window.on_mouse_event({ - let state = self.state.clone(); - - move |event: &MouseMoveEvent, _, window, cx| { - if event.pressed_button == Some(MouseButton::Left) { - state.update(cx, |state, cx| { - state.on_drag_move(event, window, cx); - }); - } - } - }); - - window.on_mouse_event({ - let state = self.state.clone(); - move |_: &MouseUpEvent, phase, _, cx| { - if !phase.bubble() { - return; - } - - // Stop auto-scroll when mouse up, and also stop selecting. - state.update(cx, |state, _| { - state.selecting = false; - }); - } - }); - } - - /// Returns the: - /// - /// - cursor bounds - /// - scroll offset - /// - 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, - last_layout: &LastLayout, - bounds: &mut Bounds, - scroll_size: Size, - _: &mut Window, - cx: &mut App, - ) -> (Option>, Point) { - 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 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 is_selected_all = selected_range.len() == state.text.len(); - - let mut cursor = state.cursor(); - if state.masked { - selected_range.start = masked_display_offset(&state.text, selected_range.start); - selected_range.end = masked_display_offset(&state.text, selected_range.end); - cursor = masked_display_offset(&state.text, cursor); - } - - 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 top_bottom_margin = - if state.mode.is_auto_grow() || visible_range.len() < BOTTOM_MARGIN_ROWS * 8 { - line_height - } else { - 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; - let mut cursor_end = None; - - let mut prev_lines_offset = 0; - let mut offset_y = px(0.); - let buffer_lines = state.display_map.lines(); - let visible_buffer_lines = &last_layout.visible_buffer_lines; - let mut vi = 0; // index into visible_buffer_lines / lines - for (ix, wrap_line) in buffer_lines.iter().enumerate() { - 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; - } - - // Check if this buffer line has a LineLayout in the compact lines vec - let line_layout = if vi < visible_buffer_lines.len() && visible_buffer_lines[vi] == ix { - let l = &lines[vi]; - vi += 1; - Some(l) - } else { - None - }; - - if let Some(line) = line_layout { - if cursor_pos.is_none() { - let offset = cursor.saturating_sub(prev_lines_offset); - if let Some(pos) = - line.position_for_index(offset, last_layout, state.cursor_line_end_affinity) - { - 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, last_layout, false) { - 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, last_layout, false) { - cursor_end = Some(line_origin + pos); - } - } - - offset_y += line.size(line_height).height; - // +1 for the last `\n` - prev_lines_offset += wrap_line.len() + 1; - } else { - // Not visible (before visible range or hidden/folded). - // Just increase the offset_y and prev_lines_offset for scroll tracking. - if prev_lines_offset >= cursor && cursor_pos.is_none() { - 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); - } - - let visible_wrap_rows = - state.display_map.visible_wrap_row_count_for_buffer_line(ix); - offset_y += line_height * visible_wrap_rows; - // +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 selection_changed = state.last_selected_range != Some(selected_range); - - if selection_changed && !is_selected_all { - // For Right alignment use 0 margin: cursor is clamped to bounds separately, - // so we never scroll the text for cursor-at-edge, avoiding a first-click jump. - let safety_margin = match last_layout.text_align { - TextAlign::Left => RIGHT_MARGIN, - TextAlign::Right => px(0.), - TextAlign::Center => CURSOR_WIDTH, - }; - - scroll_offset.x = if scroll_offset.x + cursor_pos.x - > (bounds.size.width - line_number_width - safety_margin) - { - // cursor is out of right - bounds.size.width - line_number_width - safety_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 - }; - - // 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 - }; - - // For selection to move scroll - 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; - } - if scroll_offset.y + cursor_start.y < px(0.) { - // selection start is out of top - scroll_offset.y = -cursor_start.y; - } - } else { - // TODO: Consider to remove this part, - // maybe is not necessary (But selection_reversed is needed). - if scroll_offset.x + cursor_end.x <= px(0.) { - // selection end is out of left - scroll_offset.x = -cursor_end.x; - } - if scroll_offset.y + cursor_end.y <= px(0.) { - // selection end is out of top - scroll_offset.y = -cursor_end.y; - } - } - } - - // cursor bounds - let cursor_height = match state.size { - crate::Size::Large => 1., - crate::Size::Small => 0.75, - _ => 0.85, - } * line_height; - - // For Right alignment, clamp cursor within the right edge of bounds so it - // stays visible without having to shift the text via scroll_offset. - let cursor_x = bounds.left() + cursor_pos.x + line_number_width + scroll_offset.x; - let cursor_x = if last_layout.text_align == TextAlign::Right { - cursor_x.min(bounds.right() - CURSOR_WIDTH) - } else { - cursor_x - }; - cursor_bounds = Some(Bounds::new( - point( - cursor_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; - } - scroll_offset.y = clamp_auto_grow_vertical_scroll_offset( - &state.mode, - scroll_offset.y, - scroll_size.height, - bounds.size.height, - ); - - bounds.origin += scroll_offset; - - (cursor_bounds, scroll_offset) - } - - /// Layout the match range to a Path. - pub(crate) fn layout_match_range( - range: Range, - last_layout: &LastLayout, - bounds: &Bounds, - ) -> Option> { - if range.is_empty() { - return None; - } - - if range.start < last_layout.visible_range_offset.start - || range.end > last_layout.visible_range_offset.end - { - return None; - } - - let line_height = last_layout.line_height; - let visible_top = last_layout.visible_top; - let lines = &last_layout.lines; - let line_number_width = last_layout.line_number_width; - - let start_ix = range.start; - let end_ix = range.end; - - // Start from visible_top (which already accounts for all lines before visible range) - let mut offset_y = visible_top; - let mut line_corners = vec![]; - - // Iterate only over visible (non-hidden) buffer lines - for (prev_lines_offset, line) in last_layout - .visible_line_byte_offsets - .iter() - .zip(lines.iter()) - { - let prev_lines_offset = *prev_lines_offset; - let line_size = line.size(line_height); - let line_wrap_width = line_size.width; - - let line_origin = point(px(0.), offset_y); - - let line_cursor_start = line.position_for_index( - start_ix.saturating_sub(prev_lines_offset), - last_layout, - false, - ); - let line_cursor_end = line.position_for_index( - end_ix.saturating_sub(prev_lines_offset), - last_layout, - false, - ); - - if line_cursor_start.is_some() || line_cursor_end.is_some() { - let start = line_cursor_start - .unwrap_or_else(|| line.position_for_index(0, last_layout, false).unwrap()); - - let end = line_cursor_end.unwrap_or_else(|| { - line.position_for_index(line.len(), last_layout, false) - .unwrap() - }); - - // Split the selection into multiple items - let wrapped_lines = - (end.y / line_height).ceil() as usize - (start.y / line_height).ceil() as usize; - - let mut end_x = end.x; - if wrapped_lines > 0 { - end_x = line_wrap_width; - } - - // Ensure at least 6px width for the selection for empty lines. - end_x = end_x.max(start.x + px(6.)); - - line_corners.push(Corners { - top_left: line_origin + point(start.x, start.y), - top_right: line_origin + point(end_x, start.y), - bottom_left: line_origin + point(start.x, start.y + line_height), - bottom_right: line_origin + point(end_x, start.y + line_height), - }); - - // wrapped lines - for i in 1..=wrapped_lines { - let start = point(px(0.), start.y + i as f32 * line_height); - let mut end = point(end.x, end.y + i as f32 * line_height); - if i < wrapped_lines { - end.x = line_size.width; - } - - line_corners.push(Corners { - top_left: line_origin + point(start.x, start.y), - top_right: line_origin + point(end.x, start.y), - bottom_left: line_origin + point(start.x, start.y + line_height), - bottom_right: line_origin + point(end.x, start.y + line_height), - }); - } - } - - if line_cursor_start.is_some() && line_cursor_end.is_some() { - break; - } - - offset_y += line_size.height; - } - - let mut points = vec![]; - if line_corners.is_empty() { - return None; - } - - // Fix corners to make sure the left to right direction - for corners in &mut line_corners { - if corners.top_left.x > corners.top_right.x { - std::mem::swap(&mut corners.top_left, &mut corners.top_right); - std::mem::swap(&mut corners.bottom_left, &mut corners.bottom_right); - } - } - - for corners in &line_corners { - points.push(corners.top_right); - points.push(corners.bottom_right); - points.push(corners.bottom_left); - } - - let mut rev_line_corners = line_corners.iter().rev().peekable(); - while let Some(corners) = rev_line_corners.next() { - points.push(corners.top_left); - if let Some(next) = rev_line_corners.peek() - && next.top_left.x > corners.top_left.x - { - points.push(point(next.top_left.x, corners.top_left.y)); - } - } - - // print_points_as_svg_path(&line_corners, &points); - - let path_origin = bounds.origin + point(line_number_width, px(0.)); - let first_p = *points.first().unwrap(); - let mut builder = gpui::PathBuilder::fill(); - builder.move_to(path_origin + first_p); - for p in points.iter().skip(1) { - builder.line_to(path_origin + *p); - } - - builder.build().ok() - } - - fn layout_selections( - &self, - last_layout: &LastLayout, - bounds: &mut Bounds, - window: &mut Window, - cx: &mut App, - ) -> Option> { - let state = self.state.read(cx); - if !state.focus_handle.is_focused(window) { - return None; - } - - let mut selected_range = state.selected_range; - if let Some(ime_marked_range) = &state.ime_marked_range - && !ime_marked_range.is_empty() - { - selected_range = (ime_marked_range.end..ime_marked_range.end).into(); - } - if selected_range.is_empty() { - return None; - } - - if state.masked { - selected_range.start = masked_display_offset(&state.text, selected_range.start); - selected_range.end = masked_display_offset(&state.text, selected_range.end); - } - - 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. - /// - /// Returns - /// - /// - visible_range: The visible range is based on unwrapped lines (Zero based). - /// - visible_buffer_lines: Indices of non-hidden buffer lines within the visible range. - /// - 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, Vec, 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, vec![0], visible_top); - } - - let total_lines = state.display_map.wrap_row_count(); - let mut 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; - scroll_top = clamp_auto_grow_vertical_scroll_offset( - &state.mode, - scroll_top, - line_height * total_lines, - input_height, - ); - let mut line_bottom = px(0.); - for (ix, _line) in state.display_map.lines().iter().enumerate() { - let visible_wrap_rows = state.display_map.visible_wrap_row_count_for_buffer_line(ix); - - if visible_wrap_rows == 0 { - continue; - } - - let wrapped_height = line_height * visible_wrap_rows; - line_bottom += wrapped_height; - - if line_bottom < -scroll_top { - visible_top = line_bottom - wrapped_height; - visible_range.start = ix; - } - - if line_bottom + scroll_top >= input_height { - visible_range.end = (ix + extra_rows).min(total_lines); - break; - } - } - - // Collect non-hidden buffer lines within the visible range - let mut visible_buffer_lines = Vec::with_capacity(visible_range.len()); - for ix in visible_range.start..visible_range.end { - let visible_wrap_rows = state.display_map.visible_wrap_row_count_for_buffer_line(ix); - if visible_wrap_rows > 0 { - visible_buffer_lines.push(ix); - } - } - - (visible_range, visible_buffer_lines, visible_top) - } - - /// Layout shaped lines for whitespace indicators (space and tab). - /// - /// Returns `WhitespaceIndicators` with shaped lines for space and tab characters. - fn layout_whitespace_indicators( - _state: &InputState, - text_size: Pixels, - style: &TextStyle, - window: &mut Window, - cx: &App, - ) -> Option { - // Whitespace indicators are not currently enabled. - // When re-enabled, check `state.show_whitespaces` to conditionally enable. - let _ = (text_size, style, window, cx); - None - } - - #[allow(clippy::too_many_arguments)] - fn layout_lines( - state: &InputState, - display_text: &Rope, - last_layout: &LastLayout, - font_size: Pixels, - runs: &[TextRun], - bg_segments: &[(Range, Hsla)], - whitespace_indicators: Option, - window: &mut Window, - ) -> Vec { - let is_single_line = state.mode.is_single_line(); - let buffer_lines = state.display_map.lines(); - - if is_single_line { - let shaped_line = window.text_system().shape_line( - display_text.to_string().into(), - font_size, - runs, - None, - ); - - let line_layout = LineLayout::new() - .lines(smallvec::smallvec![shaped_line]) - .with_whitespaces(whitespace_indicators); - return vec![line_layout]; - } - - // Empty to use placeholder, the placeholder is not in the wrapper map. - if state.text.len() == 0 { - let placeholder_text = display_text.to_string(); - let mut placeholder_lines = SmallVec::new(); - - for (line, line_runs) in placeholder_line_runs(&placeholder_text, runs) { - let shaped_line = window.text_system().shape_line( - line.to_string().into(), - font_size, - &line_runs, - None, - ); - placeholder_lines.push(shaped_line); - } - - // Keep placeholder lines in a single layout to stay parallel with visible_* metadata. - let line_layout = LineLayout::new() - .lines(placeholder_lines) - .with_whitespaces(whitespace_indicators); - return vec![line_layout]; - } - - let mut lines = Vec::with_capacity(last_layout.visible_buffer_lines.len()); - - for (vi, &buffer_line) in last_layout.visible_buffer_lines.iter().enumerate() { - let line_text: String = display_text.slice_line(buffer_line).into(); - let line_item = buffer_lines - .get(buffer_line) - .expect("line should exists in wrapper"); - - debug_assert_eq!(line_item.len(), line_text.len()); - - let mut wrapped_lines = SmallVec::with_capacity(1); - let line_offset = display_text.line_start_offset(buffer_line); - - for range in &line_item.wrapped_lines { - let line_runs = runs_for_range(runs, line_offset, range); - let line_runs = if bg_segments.is_empty() { - line_runs - } else { - split_runs_by_bg_segments( - last_layout.visible_line_byte_offsets[vi] + (range.start), - &line_runs, - bg_segments, - ) - }; - - let sub_line: SharedString = line_text[range.clone()].to_string().into(); - let shaped_line = window - .text_system() - .shape_line(sub_line, font_size, &line_runs, None); - - wrapped_lines.push(shaped_line); - } - - let line_layout = LineLayout::new() - .lines(wrapped_lines) - .with_whitespaces(whitespace_indicators.clone()); - lines.push(line_layout); - } - - lines - } -} - -pub(super) struct PrepaintState { - /// The lines of entire lines. - last_layout: LastLayout, - /// Size of the scrollable area by entire lines. - scroll_size: Size, - cursor_bounds: Option>, - cursor_scroll_offset: Point, - selection_path: Option>, - bounds: Bounds, -} - -impl PrepaintState { - /// Returns cursor bounds adjusted for scroll offset, if available. - fn cursor_bounds_with_scroll(&self) -> Option> { - self.cursor_bounds.map(|mut bounds| { - bounds.origin.y += self.cursor_scroll_offset.y; - bounds - }) - } -} - -impl IntoElement for TextElement { - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } -} - -/// A debug function to print points as SVG path. -#[allow(unused)] -fn print_points_as_svg_path(line_corners: &Vec>, points: Vec>) { - for corners in line_corners { - println!( - "tl: ({}, {}), tr: ({}, {}), bl: ({}, {}), br: ({}, {})", - corners.top_left.as_f32() as i32, - corners.top_left.as_f32() as i32, - corners.top_right.as_f32() as i32, - corners.top_right.as_f32() as i32, - corners.bottom_left.as_f32() as i32, - corners.bottom_left.as_f32() as i32, - corners.bottom_right.as_f32() as i32, - corners.bottom_right.as_f32() as i32, - ); - } - - if !points.is_empty() { - println!( - "M{},{}", - points[0].x.as_f32() as i32, - points[0].y.as_f32() as i32 - ); - for p in points.iter().skip(1) { - println!("L{},{}", p.x.as_f32() as i32, p.y.as_f32() as i32); - } - } -} -impl Element for TextElement { - type PrepaintState = PrepaintState; - type RequestLayoutState = (); - - fn id(&self) -> Option { - None - } - - fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { - None - } - - fn request_layout( - &mut self, - _id: Option<&GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - let state = self.state.read(cx); - let line_height = window.line_height(); - - let mut style = Style::default(); - style.size.width = relative(1.).into(); - if state.mode.is_multi_line() { - style.flex_grow = 1.0; - 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.min_size.height = line_height.into(); - } - } else { - // For single-line inputs, the minimum height should be the line height - style.size.height = line_height.into(); - }; - - (window.request_layout(style, [], cx), ()) - } - - fn prepaint( - &mut self, - _id: Option<&GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - bounds: Bounds, - _request_layout: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Self::PrepaintState { - let style = window.text_style(); - let font = style.font(); - let text_size = style.font_size.to_pixels(window.rem_size()); - - self.state.update(cx, |state, cx| { - state.display_map.set_font(font, text_size, cx); - state.display_map.ensure_text_prepared(&state.text, cx); - }); - - let state = self.state.read(cx); - let line_height = window.line_height(); - - let (visible_range, visible_buffer_lines, 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.len() == 0; - let placeholder = self.placeholder.clone(); - - let text_style = window.text_style(); - let fg = text_style.color; - let (display_text, text_color) = if is_empty { - (&Rope::from(placeholder.as_str()), cx.theme().text_muted) - } else if state.masked { - ( - &Rope::from(MASK_CHAR.to_string().repeat(text.chars().count())), - fg, - ) - } else { - (&text, fg) - }; - - // Line numbers are not used (code editor mode removed) - let line_number_width = px(0.); - - let mut bounds = bounds; - let wrap_width = if multi_line && state.soft_wrap { - Some(bounds.size.width - line_number_width - RIGHT_MARGIN) - } else { - None - }; - - let visible_line_byte_offsets: Vec = visible_buffer_lines - .iter() - .map(|&bl| state.text.line_start_offset(bl)) - .collect(); - - // For password input (masked: true), convert byte offsets to masked display byte offsets so that - // layout_match_range and position_for_index work in the correct coordinate space. - let (visible_line_byte_offsets, visible_range_offset) = if state.masked { - let offsets = visible_line_byte_offsets - .iter() - .map(|&o| masked_display_offset(&text, o)) - .collect(); - let range_offset = masked_display_offset(&text, visible_start_offset) - ..masked_display_offset(&text, visible_end_offset); - (offsets, range_offset) - } else { - ( - visible_line_byte_offsets, - visible_start_offset..visible_end_offset, - ) - }; - - let mut last_layout = LastLayout { - visible_range, - visible_buffer_lines, - visible_line_byte_offsets, - visible_top, - visible_range_offset, - line_height, - wrap_width, - line_number_width, - lines: Rc::new(vec![]), - cursor_bounds: None, - text_align: state.text_align, - content_width: bounds.size.width, - }; - - let run = TextRun { - len: display_text.len(), - font: style.font(), - color: text_color, - background_color: None, - underline: None, - strikethrough: None, - }; - let marked_run = TextRun { - len: 0, - font: style.font(), - color: text_color, - background_color: None, - underline: Some(UnderlineStyle { - thickness: px(1.), - color: Some(text_color), - wavy: false, - }), - strikethrough: None, - }; - - let runs = if !is_empty { - vec![run] - } else if let Some(ime_marked_range) = &state.ime_marked_range { - // IME marked text - vec![ - TextRun { - len: ime_marked_range.start, - ..run.clone() - }, - TextRun { - len: ime_marked_range.end - ime_marked_range.start, - underline: marked_run.underline, - ..run.clone() - }, - TextRun { - len: display_text.len() - ime_marked_range.end, - ..run.clone() - }, - ] - .into_iter() - .filter(|run| run.len > 0) - .collect() - } else { - vec![run] - }; - - // Create shaped lines for whitespace indicators before layout - let whitespace_indicators = - Self::layout_whitespace_indicators(state, text_size, &text_style, window, cx); - - let lines = Self::layout_lines( - state, - display_text, - &last_layout, - text_size, - &runs, - &[], - whitespace_indicators, - window, - ); - - let mut longest_line_width = wrap_width.unwrap_or(px(0.)); - // 1. Single line - // 2. Multi-line with soft wrap disabled. - if state.mode.is_single_line() || !state.soft_wrap { - let longest_row = state.display_map.longest_row(); - let longest_line: SharedString = state.text.slice_line(longest_row).to_string().into(); - longest_line_width = window - .text_system() - .shape_line( - longest_line.clone(), - text_size, - &[TextRun { - len: longest_line.len(), - font: style.font(), - color: gpui::black(), - background_color: None, - underline: None, - strikethrough: None, - }], - wrap_width, - ) - .width; - } - last_layout.lines = Rc::new(lines); - - let total_wrapped_lines = state.display_map.wrap_row_count(); - let empty_bottom_height = px(0.); - - let mut scroll_size = size( - 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), - ); - - // TODO: should be add some gap to right, to convenient to focus on boundary position - if last_layout.text_align == TextAlign::Right || last_layout.text_align == TextAlign::Center - { - scroll_size.width = longest_line_width + line_number_width; - } - - // `position_for_index` for example - // - // #### text - // - // Hello 世界,this is GPUI component. - // The GPUI Component is a collection of UI components for - // GPUI framework, including Button, Input, Checkbox, Radio, - // Dropdown, Tab, and more... - // - // wrap_width: 444px, line_height: 20px - // - // #### lines[0] - // - // | index | pos | line | - // |-------|------------------|------| - // | 5 | (37 px, 0.0) | 0 | - // | 38 | (261.7 px, 20.0) | 0 | - // | 40 | None | - | - // - // #### lines[1] - // - // | index | position | line | - // |-------|-----------------------|------| - // | 5 | (43.578125 px, 0.0) | 0 | - // | 56 | (422.21094 px, 0.0) | 0 | - // | 57 | (11.6328125 px, 20.0) | 1 | - // | 114 | (429.85938 px, 20.0) | 1 | - // | 115 | (11.3125 px, 40.0) | 2 | - - // Calculate the scroll offset to keep the cursor in view - - // Save the bounds before layout_cursor modifies bounds.origin with scroll_offset. - let input_bounds = bounds; - - let (cursor_bounds, cursor_scroll_offset) = - self.layout_cursor(&last_layout, &mut bounds, scroll_size, window, cx); - last_layout.cursor_bounds = cursor_bounds; - - let selection_path = self.layout_selections(&last_layout, &mut bounds, window, cx); - - let state = self.state.read(cx); - - state - .editor_scrollbar_snapshot - .set(Some(EditorScrollbarSnapshot::new( - input_bounds, - &last_layout, - scroll_size, - cursor_scroll_offset, - state, - ))); - - PrepaintState { - bounds, - last_layout, - scroll_size, - cursor_bounds, - cursor_scroll_offset, - selection_path, - } - } - - fn paint( - &mut self, - _id: Option<&GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - input_bounds: Bounds, - _request_layout: &mut Self::RequestLayoutState, - prepaint: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - 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.state.read(cx).selected_range; - let text_align = prepaint.last_layout.text_align; - - window.handle_input( - &focus_handle, - ElementInputHandler::new(bounds, self.state.clone()), - cx, - ); - - // Set Root focused_input when self is focused - if focused { - 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); - cx.notify(); - }); - } - } - - // And reset focused_input when next_frame start - window.on_next_frame({ - 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| { - root.focused_input = None; - cx.notify(); - }); - } - } - }); - - // Paint multi line text - let line_height = window.line_height(); - let origin = bounds.origin; - - let invisible_top_padding = prepaint.last_layout.visible_top; - - // Paint selections - if window.is_window_active() - && let Some(path) = prepaint.selection_path.take() - { - window.paint_path(path, cx.theme().selection); - } - - // Paint text - let mut offset_y = invisible_top_padding; - - // Keep scrollbar offset always be positive,Start from the left position - let scroll_offset = if text_align == TextAlign::Right { - (prepaint.scroll_size.width - prepaint.bounds.size.width).max(px(0.)) - } else if text_align == TextAlign::Center { - (prepaint.scroll_size.width - prepaint.bounds.size.width) - .half() - .max(px(0.)) - } else { - px(0.) - }; - - for (line, _buffer_line) in prepaint - .last_layout - .lines - .iter() - .zip(prepaint.last_layout.visible_buffer_lines.iter()) - { - let line_y = origin.y + offset_y; - let p = point( - origin.x + prepaint.last_layout.line_number_width + (scroll_offset), - line_y, - ); - - // Paint the actual line - line.paint( - p, - line_height, - text_align, - Some(prepaint.last_layout.content_width), - window, - cx, - ); - offset_y += line.size(line_height).height; - } - - // Paint blinking cursor - if focused - && show_cursor - && let Some(cursor_bounds) = prepaint.cursor_bounds_with_scroll() - { - window.paint_quad(fill(cursor_bounds, cx.theme().cursor)); - } - - 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.update_scroll_offset(Some(prepaint.cursor_scroll_offset), cx); - state.deferred_scroll_offset = None; - - cx.notify(); - }); - - self.paint_mouse_listeners(window, cx); - } -} - -/// Split placeholder text into display lines and trim runs to each line. -fn placeholder_line_runs<'a>( - display_text: &'a str, - runs: &[TextRun], -) -> Vec<(&'a str, Vec)> { - let mut result = Vec::new(); - let mut line_offset = 0; - - for line in display_text.split('\n') { - let line_runs = runs_for_range(runs, line_offset, &(0..line.len())); - debug_assert_eq!( - line_runs.iter().map(|run| run.len).sum::(), - line.len() - ); - result.push((line, line_runs)); - // Advance in the whole-placeholder coordinate space, including the separator. - line_offset += line.len() + 1; - } - - result -} - -/// Get the runs for the given range. -/// -/// The range is the byte range of the wrapped line. -pub(super) fn runs_for_range( - runs: &[TextRun], - line_offset: usize, - range: &Range, -) -> Vec { - let mut result = vec![]; - let range = (line_offset + range.start)..(line_offset + range.end); - let mut cursor = 0; - - for run in runs { - let run_start = cursor; - let run_end = cursor + run.len; - - if run_end <= range.start { - cursor = run_end; - continue; - } - - if run_start >= range.end { - break; - } - - let start = range.start.max(run_start) - run_start; - let end = range.end.min(run_end) - run_start; - let len = end - start; - - if len > 0 { - result.push(TextRun { len, ..run.clone() }); - } - - cursor = run_end; - } - - result -} - -fn split_runs_by_bg_segments( - start_offset: usize, - runs: &[TextRun], - bg_segments: &[(Range, Hsla)], -) -> Vec { - let mut result = vec![]; - - let mut cursor = start_offset; - for run in runs { - let mut run_start = cursor; - let run_end = cursor + run.len; - - for (bg_range, bg_color) in bg_segments { - if run_end <= bg_range.start || run_start >= bg_range.end { - continue; - } - - // Overlap exists - if run_start < bg_range.start { - // Add the part before the background range - result.push(TextRun { - len: bg_range.start - run_start, - ..run.clone() - }); - } - - // Add the overlapping part with background color - let overlap_start = run_start.max(bg_range.start); - let overlap_end = run_end.min(bg_range.end); - let text_color = if bg_color.l >= 0.5 { - gpui::black() - } else { - gpui::white() - }; - - let run_len = overlap_end.saturating_sub(overlap_start); - if run_len > 0 { - result.push(TextRun { - len: run_len, - color: text_color, - ..run.clone() - }); - - cursor = bg_range.end; - run_start = cursor; - } - } - - if run_end > cursor { - // Add the part after the background range - result.push(TextRun { - len: run_end - cursor, - ..run.clone() - }); - } - - cursor = run_end; - } - - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_editor_scrollbar_layout_uses_current_scroll_size() { - let input_bounds = Bounds::new(point(px(10.), px(20.)), size(px(300.), px(80.))); - let paddings = Edges { - top: px(2.), - right: px(3.), - bottom: px(5.), - left: px(7.), - }; - - let layout = - EditorScrollbarLayout::new(input_bounds, px(40.), size(px(1000.), px(200.)), paddings); - - assert_eq!( - layout.bounds, - Bounds::new(point(px(47.), px(18.)), size(px(266.), px(87.))) - ); - assert_eq!(layout.scroll_size, size(px(976.), px(200.))); - - let layout_without_gutter = - EditorScrollbarLayout::new(input_bounds, px(0.), size(px(500.), px(120.)), paddings); - - assert_eq!( - layout_without_gutter.bounds, - Bounds::new(point(px(10.), px(18.)), size(px(303.), px(87.))) - ); - assert_eq!(layout_without_gutter.scroll_size, size(px(513.), px(120.))); - } - - #[test] - fn test_auto_grow_scroll_offset_is_clamped_to_current_viewport() { - let mode = InputMode::auto_grow(3, 8); - - assert_eq!( - clamp_auto_grow_vertical_scroll_offset(&mode, px(-260.), px(340.), px(160.)), - px(-180.) - ); - assert_eq!( - clamp_auto_grow_vertical_scroll_offset(&mode, px(-40.), px(340.), px(160.)), - px(-40.) - ); - assert_eq!( - clamp_auto_grow_vertical_scroll_offset(&mode, px(20.), px(340.), px(160.)), - px(0.) - ); - - let plain_text = InputMode::plain_text().multi_line(true); - assert_eq!( - clamp_auto_grow_vertical_scroll_offset(&plain_text, px(-260.), px(340.), px(160.)), - px(-260.) - ); - } - - #[test] - fn test_runs_for_range() { - let run = TextRun { - len: 0, - font: gpui::font(".SystemUIFont"), - color: gpui::black(), - background_color: None, - underline: None, - strikethrough: None, - }; - - // use hello this-is-test - let runs = vec![ - // use - TextRun { - len: 3, - ..run.clone() - }, - // \s - TextRun { - len: 1, - ..run.clone() - }, - // hello - TextRun { - len: 5, - ..run.clone() - }, - // \s - TextRun { - len: 1, - ..run.clone() - }, - // this-is-test - TextRun { - len: 12, - ..run.clone() - }, - ]; - - #[track_caller] - fn assert_runs(actual: Vec, expected: &[usize]) { - let left = actual.iter().map(|run| run.len).collect::>(); - assert_eq!(left, expected); - } - - assert_runs(runs_for_range(&runs, 0, &(0..0)), &[]); - assert_runs(runs_for_range(&runs, 0, &(0..100)), &[3, 1, 5, 1, 12]); - - assert_runs(runs_for_range(&runs, 0, &(0..6)), &[3, 1, 2]); - assert_runs(runs_for_range(&runs, 0, &(1..6)), &[2, 1, 2]); - assert_runs(runs_for_range(&runs, 0, &(3..10)), &[1, 5, 1]); - assert_runs(runs_for_range(&runs, 0, &(5..8)), &[3]); - assert_runs(runs_for_range(&runs, 3, &(0..3)), &[1, 2]); - assert_runs(runs_for_range(&runs, 3, &(2..10)), &[4, 1, 3]); - assert_runs(runs_for_range(&runs, 9, &(0..8)), &[1, 7]); - } - - #[test] - fn test_placeholder_line_runs() { - let run = TextRun { - len: 0, - font: gpui::font(".SystemUIFont"), - color: gpui::black(), - background_color: None, - underline: None, - strikethrough: None, - }; - - let runs = vec![ - TextRun { - len: 2, - ..run.clone() - }, - TextRun { - len: 2, - ..run.clone() - }, - TextRun { len: 1, ..run }, - ]; - - let placeholder_runs = placeholder_line_runs("ab\n\nc", &runs); - - let lines = placeholder_runs - .iter() - .map(|(line, _)| *line) - .collect::>(); - assert_eq!(lines, vec!["ab", "", "c"]); - - let run_lengths = placeholder_runs - .iter() - .map(|(_, line_runs)| line_runs.iter().map(|run| run.len).collect::>()) - .collect::>(); - assert_eq!(run_lengths, vec![vec![2], vec![], vec![1]]); - } - - #[test] - fn test_split_runs_by_bg_segments() { - let run = TextRun { - len: 0, - font: gpui::font(".SystemUIFont"), - color: gpui::blue(), - background_color: None, - underline: None, - strikethrough: None, - }; - - let runs = vec![ - TextRun { - len: 5, - ..run.clone() - }, - TextRun { - len: 7, - ..run.clone() - }, - TextRun { - len: 24, - ..run.clone() - }, - ]; - - let bg_segments = vec![(8..12, gpui::red()), (12..18, gpui::blue())]; - let result = split_runs_by_bg_segments(5, &runs, &bg_segments); - assert_eq!( - result.iter().map(|run| run.len).collect::>(), - vec![3, 2, 2, 5, 1, 23] - ); - assert_eq!(result[0].color, gpui::blue()); - assert_eq!(result[1].color, gpui::black()); - assert_eq!(result[2].color, gpui::black()); - assert_eq!(result[3].color, gpui::black()); - assert_eq!(result[4].color, gpui::black()); - assert_eq!(result[5].color, gpui::blue()); - } -} diff --git a/crates/ui/src/input/indent.rs b/crates/ui/src/input/indent.rs deleted file mode 100644 index 2f38e49b..00000000 --- a/crates/ui/src/input/indent.rs +++ /dev/null @@ -1,269 +0,0 @@ -use gpui::{Context, EntityInputHandler, SharedString, Window}; -use ropey::RopeSlice; - -use crate::input::mode::InputMode; -use crate::input::{Indent, IndentInline, InputState, Outdent, OutdentInline}; - -#[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() - } - } - - /// Count the indent size of the line in spaces. - pub fn indent_count(&self, line: &RopeSlice) -> usize { - let mut count = 0; - for ch in line.chars() { - match ch { - '\t' => count += self.tab_size, - ' ' => count += 1, - _ => break, - } - } - - count - } -} - -impl InputState { - /// Set the tab size for the input. - /// - /// Only for [`InputMode::PlainText`] mode with multi_line. - pub fn tab_size(mut self, tab: TabSize) -> Self { - debug_assert!(self.mode.is_multi_line()); - if let InputMode::PlainText { tab: t, .. } = &mut self.mode { - *t = tab; - } - self - } - - 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) { - if !self.mode.is_indentable() { - cx.propagate(); - return; - }; - - let tab_indent = self.mode.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) { - if !self.mode.is_indentable() { - cx.propagate(); - return; - }; - - let tab_indent = self.mode.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(); - } - } - } -} - -#[cfg(test)] -mod tests { - use ropey::RopeSlice; - - use super::TabSize; - - #[test] - fn test_tab_size() { - let tab = TabSize { - tab_size: 2, - hard_tabs: false, - }; - assert_eq!(tab.to_string(), " "); - let tab = TabSize { - tab_size: 4, - hard_tabs: false, - }; - assert_eq!(tab.to_string(), " "); - - let tab = TabSize { - tab_size: 2, - hard_tabs: true, - }; - assert_eq!(tab.to_string(), "\t"); - let tab = TabSize { - tab_size: 4, - hard_tabs: true, - }; - assert_eq!(tab.to_string(), "\t"); - } - - #[test] - fn test_tab_size_indent_count() { - let tab = TabSize { - tab_size: 4, - hard_tabs: false, - }; - assert_eq!(tab.indent_count(&RopeSlice::from("abc")), 0); - assert_eq!(tab.indent_count(&RopeSlice::from(" abc")), 2); - assert_eq!(tab.indent_count(&RopeSlice::from(" abc")), 4); - assert_eq!(tab.indent_count(&RopeSlice::from("\tabc")), 4); - assert_eq!(tab.indent_count(&RopeSlice::from(" \tabc")), 6); - assert_eq!(tab.indent_count(&RopeSlice::from(" \t abc ")), 6); - assert_eq!(tab.indent_count(&RopeSlice::from("abc")), 0); - } -} diff --git a/crates/ui/src/input/input.rs b/crates/ui/src/input/input.rs index 5efa0a07..d9dddd32 100644 --- a/crates/ui/src/input/input.rs +++ b/crates/ui/src/input/input.rs @@ -1,31 +1,76 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ - AnyElement, App, DefiniteLength, Edges, EdgesRefinement, Entity, Hsla, InteractiveElement as _, - IntoElement, MouseButton, ParentElement as _, Rems, RenderOnce, StyleRefinement, Styled, - TextAlign, Window, div, px, relative, + AnyElement, App, DefiniteLength, Edges, Entity, Hsla, InteractiveElement as _, IntoElement, + MouseButton, ParentElement as _, Pixels, Rems, RenderOnce, StyleRefinement, Styled, TextAlign, + Window, div, px, relative, }; +use gpui_base::InputBase; +use gpui_base::input::{InputBaseState, InputEditorStyle, InputMode, InputModeKind, TextareaMode}; use theme::ActiveTheme; -use super::InputState; -use super::element::EditorScrollbar; use crate::button::{Button, ButtonVariants as _}; use crate::indicator::Indicator; use crate::input::clear_button; use crate::{IconName, Selectable, Sizable, Size, StyleSized, StyledExt, h_flex, v_flex}; -/// Returns `(background, foreground)` colors for input-like components. -pub(crate) fn input_style(disabled: bool, cx: &App) -> (Hsla, Hsla) { +/// The background of an input frame, which reads muted while the input is disabled. +fn input_background(disabled: bool, cx: &App) -> Hsla { if disabled { - (cx.theme().surface_background, cx.theme().text_muted) + cx.theme().surface_background } else { - (cx.theme().elevated_surface_background, cx.theme().text) + cx.theme().elevated_surface_background } } -/// A text input element bind to an [`InputState`]. +/// The colors base paints input text with, read from the coop theme. +/// +/// Base fills in any color left transparent from its own palette, and that +/// palette is only a projection of this one, so every color coop paints with is +/// named here rather than left to resolve. +fn input_editor_style(cx: &App) -> InputEditorStyle { + let theme = cx.theme(); + InputEditorStyle { + foreground: theme.text, + muted_foreground: theme.text_muted, + background: theme.elevated_surface_background, + border: theme.border, + selection: theme.selection, + caret: theme.cursor, + ..InputEditorStyle::default() + } +} + +/// The input's own padding, resolved to pixels. +/// +/// Base applies the multi-line padding itself so that the text, the gutter, and +/// the scrollbar share one inset, and the single-line frame carries its own. +/// Both come from the same size table, resolved through the window's rem size. +fn input_paddings(size: Size, style: &StyleRefinement, window: &Window) -> Edges { + let mut probe = div().input_px(size).input_py(size).refine_style(style); + let padding = probe.style().padding.clone(); + let base_size = window.text_style().font_size; + let rem_size = window.rem_size(); + let resolve = |value: Option| { + value + .map(|value| value.to_pixels(base_size, rem_size)) + .unwrap_or(px(0.)) + }; + + Edges { + left: resolve(padding.left), + right: resolve(padding.right), + top: resolve(padding.top), + bottom: resolve(padding.bottom), + } +} + +/// A text input element bound to an [`InputState`] or a [`TextareaState`]. +/// +/// The editing kind lives on the state, so `Input::new` accepts either and +/// infers which one is rendered. #[derive(IntoElement)] -pub struct Input { - state: Entity, +pub struct Input { + state: Entity>, style: StyleRefinement, size: Size, prefix: Option, @@ -39,14 +84,17 @@ pub struct Input { selected: bool, } -impl Sizable for Input { +/// A styled multi-line text input. +pub type Textarea = Input; + +impl Sizable for Input { fn with_size(mut self, size: impl Into) -> Self { self.size = size.into(); self } } -impl Selectable for Input { +impl Selectable for Input { fn selected(mut self, selected: bool) -> Self { self.selected = selected; self @@ -57,9 +105,9 @@ impl Selectable for Input { } } -impl Input { - /// Create a new [`Input`] element bind to the [`InputState`]. - pub fn new(state: &Entity) -> Self { +impl Input { + /// Create a new [`Input`] element bind to the given state. + pub fn new(state: &Entity>) -> Self { Self { state: state.clone(), size: Size::default(), @@ -128,8 +176,7 @@ impl Input { self } - fn render_toggle_mask_button(state: &Entity, cx: &App) -> impl IntoElement { - let _masked = state.read(cx).masked; + fn render_toggle_mask_button(state: &Entity>) -> impl IntoElement { Button::new("toggle-mask") .icon(IconName::Eye) .xsmall() @@ -137,78 +184,42 @@ impl Input { .tab_stop(false) .on_click({ let state = state.clone(); - move |_, window, cx| { - state.update(cx, |state, cx| { - state.set_masked(!state.masked, window, cx); - }) - } + move |_, window, cx| state.update(cx, |state, cx| state.toggle_masked(window, cx)) }) } - - /// This method must after the refine_style. - fn render_editor( - paddings: EdgesRefinement, - input_state: &Entity, - state: &InputState, - window: &Window, - ) -> impl IntoElement { - let base_size = window.text_style().font_size; - let rem_size = window.rem_size(); - - let paddings = Edges { - left: paddings - .left - .map(|v| v.to_pixels(base_size, rem_size)) - .unwrap_or(px(0.)), - right: paddings - .right - .map(|v| v.to_pixels(base_size, rem_size)) - .unwrap_or(px(0.)), - top: paddings - .top - .map(|v| v.to_pixels(base_size, rem_size)) - .unwrap_or(px(0.)), - bottom: paddings - .bottom - .map(|v| v.to_pixels(base_size, rem_size)) - .unwrap_or(px(0.)), - }; - - state.editor_scrollbar_paddings.set(paddings); - state.editor_scrollbar_snapshot.set(None); - - v_flex().size_full().child( - div() - .relative() - .flex_1() - .child(input_state.clone()) - .child(EditorScrollbar::new(input_state.clone())), - ) - } } -impl Styled for Input { +impl Styled for Input { fn style(&mut self) -> &mut StyleRefinement { &mut self.style } } -impl RenderOnce for Input { +impl RenderOnce for Input { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { const LINE_HEIGHT: Rems = Rems(1.25); let text_align = self.style.text.text_align.unwrap_or(TextAlign::Left); - self.state.update(cx, |state, _| { - state.disabled = self.disabled; - state.size = self.size; - // Only for single line mode - if state.mode.is_single_line() { - state.text_align = text_align; + let multi_line = self.state.read(cx).is_multi_line(); + let editor_paddings = if multi_line { + input_paddings(self.size, &self.style, window) + } else { + Edges::default() + }; + self.state.update(cx, |state, cx| { + state.set_editor_style(input_editor_style(cx)); + state.set_editor_paddings(editor_paddings); + state.set_disabled(self.disabled, cx); + if state.is_single_line() { + state.set_text_align(text_align, cx); } }); let state = self.state.read(cx); - let _focused = state.focus_handle.is_focused(window) && !state.disabled; + let presentation = state.presentation(); + let disabled = presentation.is_disabled(); + let loading = presentation.is_loading(); + let text_is_empty = state.text().len() == 0; let gap_x = match self.size { Size::Small => px(4.), @@ -216,117 +227,49 @@ impl RenderOnce for Input { _ => px(6.), }; - let (bg, _) = input_style(state.disabled, cx); + let background = input_background(disabled, cx); + let show_clear_button = + self.cleanable && state.is_editable() && !loading && !text_is_empty && !multi_line; + let has_suffix = self.suffix.is_some() || loading || self.mask_toggle || show_clear_button; let prefix = self.prefix; let suffix = self.suffix; - let show_clear_button = self.cleanable - && !state.disabled - && !state.loading - && state.text.len() > 0 - && state.mode.is_single_line(); - let has_suffix = suffix.is_some() || state.loading || self.mask_toggle || show_clear_button; + let state_entity = self.state.clone(); - div() - .id(("input", self.state.entity_id())) + InputBase::new(("input", self.state.entity_id())) .flex() - .key_context(crate::input::CONTEXT) - .track_focus(&state.focus_handle.clone()) - .tab_index(self.tab_index) - .when(!state.disabled, |this| { - this.on_action(window.listener_for(&self.state, InputState::backspace)) - .on_action(window.listener_for(&self.state, InputState::delete)) - .on_action( - window.listener_for(&self.state, InputState::delete_to_beginning_of_line), - ) - .on_action(window.listener_for(&self.state, InputState::delete_to_end_of_line)) - .on_action(window.listener_for(&self.state, InputState::delete_previous_word)) - .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::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.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::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)) - .on_action(window.listener_for(&self.state, InputState::select_to_end_of_line)) - .on_action(window.listener_for(&self.state, InputState::select_to_previous_word)) - .on_action(window.listener_for(&self.state, InputState::select_to_next_word)) - .on_action(window.listener_for(&self.state, InputState::home)) - .on_action(window.listener_for(&self.state, InputState::end)) - .on_action(window.listener_for(&self.state, InputState::move_to_start)) - .on_action(window.listener_for(&self.state, InputState::move_to_end)) - .on_action(window.listener_for(&self.state, InputState::move_to_previous_word)) - .on_action(window.listener_for(&self.state, InputState::move_to_next_word)) - .on_action(window.listener_for(&self.state, InputState::select_to_start)) - .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_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::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) - .input_px(self.size) - .input_py(self.size) + .when(!multi_line, |this| { + this.input_px(self.size).input_py(self.size) + }) .input_h(self.size) - .input_font_size(self.size) - .when(!self.disabled, |this| this.cursor_text()) + .when(!disabled, |this| this.cursor_text()) + .on_mouse_down(MouseButton::Left, { + let state_entity = state_entity.clone(); + move |_, window, cx| state_entity.update(cx, |state, cx| state.focus(window, cx)) + }) .items_center() - .when(state.mode.is_multi_line(), |this| { + .when(multi_line, |this| { this.h_auto() .when_some(self.height, |this, height| this.h(height)) }) .when(self.appearance, |this| { - this.bg(bg) + this.bg(background) .when(self.disabled, |this| this.opacity(0.5)) .rounded(cx.theme().radius) }) - .items_center() + .tab_index(self.tab_index) .gap(gap_x) .refine_style(&self.style) .children(prefix) - .when(state.mode.is_multi_line(), |mut this| { - let paddings = this.style().padding.clone(); - this.child(Self::render_editor(paddings, &self.state, state, window)) - }) - .when(!state.mode.is_multi_line(), |this| { - this.child(self.state.clone()) + .when(!multi_line, |this| this.child(state_entity.clone())) + .when(multi_line, |this| { + this.child( + v_flex() + .size_full() + .child(div().relative().flex_1().child(state_entity.clone())), + ) }) .when(has_suffix, |this| { this.pr_2().child( @@ -334,13 +277,13 @@ impl RenderOnce for Input { .id("suffix") .gap(gap_x) .items_center() - .when(state.loading, |this| this.child(Indicator::new())) + .when(loading, |this| this.child(Indicator::new())) .when(self.mask_toggle, |this| { - this.child(Self::render_toggle_mask_button(&self.state, cx)) + this.child(Self::render_toggle_mask_button(&state_entity)) }) .when(show_clear_button, |this| { this.child(clear_button(cx).on_click({ - let state = self.state.clone(); + let state = state_entity.clone(); move |_, window, cx| { state.update(cx, |state, cx| { state.clean(window, cx); diff --git a/crates/ui/src/input/mask_pattern.rs b/crates/ui/src/input/mask_pattern.rs deleted file mode 100644 index e78f234a..00000000 --- a/crates/ui/src/input/mask_pattern.rs +++ /dev/null @@ -1,409 +0,0 @@ -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 - && !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) - && 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 de8e497d..077215f6 100644 --- a/crates/ui/src/input/mod.rs +++ b/crates/ui/src/input/mod.rs @@ -1,27 +1,7 @@ -pub(super) const MASK_CHAR: char = '*'; - -mod blink_cursor; -mod change; mod clear_button; -mod cursor; -mod display_map; -mod element; -mod indent; #[allow(clippy::module_inception)] mod input; -mod mask_pattern; -mod mode; -mod movement; -mod rope_ext; -mod selection; -mod state; pub(crate) use clear_button::*; -pub use cursor::*; -pub use display_map::DisplayMap; -pub use indent::TabSize; +pub use gpui_base::input::{InputEvent, InputState, TextareaState}; pub use input::*; -pub use mask_pattern::MaskPattern; -pub use rope_ext::{InputEdit, Point, RopeExt, RopeLines}; -pub use ropey::Rope; -pub use state::*; diff --git a/crates/ui/src/input/mode.rs b/crates/ui/src/input/mode.rs deleted file mode 100644 index 30b1f43c..00000000 --- a/crates/ui/src/input/mode.rs +++ /dev/null @@ -1,145 +0,0 @@ -use super::display_map::DisplayMap; - -#[derive(Clone)] -pub(crate) enum InputMode { - /// A plain text input mode. - PlainText { - multi_line: bool, - tab: crate::input::indent::TabSize, - rows: usize, - }, - /// An auto grow input mode. - AutoGrow { - rows: usize, - min_rows: usize, - max_rows: usize, - }, -} - -impl Default for InputMode { - fn default() -> Self { - InputMode::plain_text() - } -} - -#[allow(unused)] -impl InputMode { - /// Create a plain input mode with default settings. - pub(super) fn plain_text() -> Self { - InputMode::PlainText { - multi_line: false, - tab: crate::input::indent::TabSize::default(), - rows: 1, - } - } - - /// Create an auto grow input mode with given min and max rows. - pub(super) fn auto_grow(min_rows: usize, max_rows: usize) -> Self { - InputMode::AutoGrow { - rows: min_rows, - min_rows, - max_rows, - } - } - - pub(super) fn multi_line(mut self, multi_line: bool) -> Self { - match &mut self { - InputMode::PlainText { multi_line: ml, .. } => *ml = multi_line, - InputMode::AutoGrow { .. } => {} - } - self - } - - #[inline] - pub(super) fn is_single_line(&self) -> bool { - !self.is_multi_line() - } - - #[inline] - pub(super) fn is_auto_grow(&self) -> bool { - matches!(self, InputMode::AutoGrow { .. }) - } - - #[inline] - pub(super) fn is_multi_line(&self) -> bool { - match self { - InputMode::PlainText { multi_line, .. } => *multi_line, - InputMode::AutoGrow { max_rows, .. } => *max_rows > 1, - } - } - - pub(super) fn set_rows(&mut self, new_rows: usize) { - match self { - InputMode::PlainText { 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, display_map: &DisplayMap) { - if self.is_single_line() { - return; - } - - let wrapped_lines = display_map.wrap_row_count(); - self.set_rows(wrapped_lines); - } - - /// At least 1 row be return. - pub(super) fn rows(&self) -> usize { - if !self.is_multi_line() { - return 1; - } - - match self { - InputMode::PlainText { rows, .. } => *rows, - InputMode::AutoGrow { rows, .. } => *rows, - } - .max(1) - } - - /// At least 1 row be return. - #[allow(unused)] - pub(super) fn min_rows(&self) -> usize { - match self { - InputMode::AutoGrow { min_rows, .. } => *min_rows, - _ => 1, - } - .max(1) - } - - #[allow(unused)] - pub(super) fn max_rows(&self) -> usize { - if !self.is_multi_line() { - return 1; - } - - match self { - InputMode::AutoGrow { max_rows, .. } => *max_rows, - _ => usize::MAX, - } - } - - #[inline] - pub(super) fn is_indentable(&self) -> bool { - match self { - InputMode::PlainText { multi_line, .. } => *multi_line, - _ => false, - } - } - - #[inline] - pub(super) fn tab_size(&self) -> crate::input::indent::TabSize { - match self { - InputMode::PlainText { tab, .. } => *tab, - _ => crate::input::indent::TabSize::default(), - } - } -} diff --git a/crates/ui/src/input/movement.rs b/crates/ui/src/input/movement.rs deleted file mode 100644 index 5e6160f1..00000000 --- a/crates/ui/src/input/movement.rs +++ /dev/null @@ -1,264 +0,0 @@ -use gpui::{Context, Point, Window}; - -use crate::input::{ - InputState, MoveDown, MoveEnd, MoveHome, MoveLeft, MovePageDown, MovePageUp, MoveRight, - MoveToEnd, MoveToNextWord, MoveToPreviousWord, MoveToStart, MoveUp, RopeExt as _, -}; - -#[derive(Clone, Copy, PartialEq, Eq)] -pub(crate) enum MoveDirection { - Up, - Down, -} - -impl InputState { - /// Called after moving the cursor. Updates preferred_column if we know where the cursor now is. - pub(super) fn update_preferred_column(&mut self) { - let Some(last_layout) = &self.last_layout else { - self.preferred_column = None; - return; - }; - - let point = self.text.offset_to_point(self.cursor()); - let Some(line) = last_layout.line(point.row) else { - self.preferred_column = None; - return; - }; - - let Some(pos) = line.position_for_index(point.column, last_layout, false) else { - self.preferred_column = None; - return; - }; - - self.preferred_column = Some((pos.x, point.column)); - } - - /// Move the cursor to the given offset. - /// - /// The offset is the UTF-8 offset. - /// - /// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset. - pub(crate) fn move_to( - &mut self, - offset: usize, - direction: Option, - cx: &mut Context, - ) { - let offset = offset.clamp(0, self.text.len()); - self.cursor_line_end_affinity = false; - self.selected_range = (offset..offset).into(); - self.scroll_to(offset, direction, cx); - self.pause_blink_cursor(cx); - self.update_preferred_column(); - cx.notify() - } - - /// Move the cursor vertically by one line (up or down) while preserving the column if possible. - /// - /// move_lines: Number of lines to move vertically (positive for down, negative for up). - pub(super) fn move_vertical( - &mut self, - move_lines: isize, - _: &mut Window, - cx: &mut Context, - ) { - if self.mode.is_single_line() { - return; - } - let Some(last_layout) = &self.last_layout else { - return; - }; - - let offset = self.cursor(); - let was_preferred_column = self.preferred_column; - - let mut display_point = self.display_map.offset_to_wrap_display_point(offset); - - // Convert wrap row → display row (skips folded rows), move, then convert back - let current_display_row = self - .display_map - .wrap_row_to_display_row(display_point.row) - .unwrap_or_else(|| { - self.display_map - .nearest_visible_display_row(display_point.row) - }); - let max_display_row = self.display_map.display_row_count().saturating_sub(1); - let target_display_row = current_display_row - .saturating_add_signed(move_lines) - .min(max_display_row); - let target_wrap_row = self - .display_map - .display_row_to_wrap_row(target_display_row) - .unwrap_or(display_point.row); - - display_point.row = target_wrap_row; - display_point.column = 0; - let mut new_offset = self.display_map.wrap_display_point_to_offset(display_point); - - if let Some((preferred_x, column)) = was_preferred_column { - // Get display point again to update local_row. - let mut next_display_point = self.display_map.offset_to_wrap_display_point(new_offset); - next_display_point.column = 0; - let next_point = self - .display_map - .wrap_display_point_to_point(next_display_point); - let line_start_offset = self.text.line_start_offset(next_point.row); - - // If in visible range, prefer to use position to get column. - if let Some(line) = last_layout.line(next_point.row) { - if let Some(x) = line.closest_index_for_position( - Point { - x: preferred_x, - y: next_display_point.local_row * last_layout.line_height, - }, - last_layout, - ) { - new_offset = line_start_offset + x; - } - } else { - // Not in visible range, use column directly. - let max_line_len = self.text.slice_line(next_point.row).len(); - new_offset = line_start_offset + column.min(max_line_len); - } - } - - self.pause_blink_cursor(cx); - let direction = if move_lines < 0 { - MoveDirection::Up - } else { - MoveDirection::Down - }; - self.move_to(new_offset, Some(direction), cx); - // Set back the preferred_column - self.preferred_column = was_preferred_column; - cx.notify(); - } - - 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()), None, cx); - } else { - self.move_to(self.selected_range.start, None, cx) - } - } - - 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), None, cx); - } else { - self.move_to(self.selected_range.end, None, cx) - } - } - - 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)), - Some(MoveDirection::Up), - cx, - ); - } - self.pause_blink_cursor(cx); - self.move_vertical(-1, window, cx); - } - - 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)), - Some(MoveDirection::Down), - cx, - ); - } - - self.pause_blink_cursor(cx); - self.move_vertical(1, window, cx); - } - - pub(super) fn page_up(&mut self, _: &MovePageUp, window: &mut Window, cx: &mut Context) { - if self.mode.is_single_line() { - return; - } - - 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 page_down( - &mut self, - _: &MovePageDown, - window: &mut Window, - cx: &mut Context, - ) { - if self.mode.is_single_line() { - return; - } - - 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 home(&mut self, _: &MoveHome, _: &mut Window, cx: &mut Context) { - self.pause_blink_cursor(cx); - let offset = self.start_of_line(); - self.move_to(offset, Some(MoveDirection::Up), cx); - } - - pub(super) fn end(&mut self, _: &MoveEnd, _: &mut Window, cx: &mut Context) { - self.pause_blink_cursor(cx); - let offset = self.end_of_line(); - self.move_to(offset, Some(MoveDirection::Down), cx); - self.cursor_line_end_affinity = true; - } - - pub(super) fn move_to_start( - &mut self, - _: &MoveToStart, - _: &mut Window, - cx: &mut Context, - ) { - self.move_to(0, None, cx); - } - - pub(super) fn move_to_end(&mut self, _: &MoveToEnd, _: &mut Window, cx: &mut Context) { - self.move_to(self.text.len(), None, cx); - } - - pub(super) fn move_to_previous_word( - &mut self, - _: &MoveToPreviousWord, - _: &mut Window, - cx: &mut Context, - ) { - let offset = self.previous_start_of_word(); - self.move_to(offset, None, cx); - } - - pub(super) fn move_to_next_word( - &mut self, - _: &MoveToNextWord, - _: &mut Window, - cx: &mut Context, - ) { - let offset = self.next_end_of_word(); - self.move_to(offset, None, cx); - } -} diff --git a/crates/ui/src/input/rope_ext.rs b/crates/ui/src/input/rope_ext.rs deleted file mode 100644 index 609112a9..00000000 --- a/crates/ui/src/input/rope_ext.rs +++ /dev/null @@ -1,456 +0,0 @@ -use std::ops::Range; - -use ropey::{LineType, Rope, RopeSlice}; -use sum_tree::Bias; -#[cfg(not(target_family = "wasm"))] -pub use tree_sitter::{InputEdit, Point}; - -#[cfg(target_family = "wasm")] -/// Stub type for tree-sitter Point on WASM (tree-sitter not available). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct Point { - pub row: usize, - pub column: usize, -} - -#[cfg(target_family = "wasm")] -impl Point { - pub fn new(row: usize, column: usize) -> Self { - Self { row, column } - } -} - -#[cfg(target_family = "wasm")] -/// Stub type for tree-sitter InputEdit on WASM (tree-sitter not available). -#[derive(Debug, Clone, Copy)] -pub struct InputEdit { - pub start_byte: usize, - pub old_end_byte: usize, - pub new_end_byte: usize, - pub start_position: Point, - pub old_end_position: Point, - pub new_end_position: Point, -} - -pub type Position = lsp_types::Position; - -/// An iterator over the lines of a `Rope`. -pub struct RopeLines<'a> { - rope: &'a Rope, - row: usize, - end_row: usize, -} - -impl<'a> RopeLines<'a> { - /// Create a new `RopeLines` iterator. - pub fn new(rope: &'a Rope) -> Self { - let end_row = rope.lines_len(); - Self { - row: 0, - end_row, - rope, - } - } -} -impl<'a> Iterator for RopeLines<'a> { - type Item = RopeSlice<'a>; - - #[inline] - fn next(&mut self) -> Option { - if self.row >= self.end_row { - return None; - } - - let line = self.rope.slice_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<'_> {} - -/// An extension trait for [`Rope`] to provide additional utility methods. -pub trait RopeExt { - /// Start offset of the line at the given row (0-based) index. - /// - /// # Example - /// - /// ``` - /// use gpui_component::{Rope, RopeExt}; - /// - /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); - /// assert_eq!(rope.line_start_offset(0), 0); - /// assert_eq!(rope.line_start_offset(1), 6); - /// ``` - 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. - /// - /// ``` - /// use gpui_component::{Rope, RopeExt}; - /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); - /// assert_eq!(rope.line_end_offset(0), 5); // "Hello\n" - /// assert_eq!(rope.line_end_offset(1), 12); // "World\r\n" - /// ``` - fn line_end_offset(&self, row: usize) -> usize; - - /// Return a line slice at the given row (0-based) index. including `\r` if present, but not `\n`. - /// - /// ``` - /// use gpui_component::{Rope, RopeExt}; - /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); - /// assert_eq!(rope.slice_line(0).to_string(), "Hello"); - /// assert_eq!(rope.slice_line(1).to_string(), "World\r"); - /// assert_eq!(rope.slice_line(2).to_string(), "This is a test 中文"); - /// assert_eq!(rope.slice_line(6).to_string(), ""); // out of bounds - /// ``` - fn slice_line(&self, row: usize) -> RopeSlice<'_>; - - /// Return a slice of rows in the given range (0-based, end exclusive). - /// - /// If the range is out of bounds, it will be clamped to the valid range. - /// - /// ``` - /// use gpui_component::{Rope, RopeExt}; - /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); - /// assert_eq!(rope.slice_lines(0..2).to_string(), "Hello\nWorld\r"); - /// assert_eq!(rope.slice_lines(1..3).to_string(), "World\r\nThis is a test 中文"); - /// assert_eq!(rope.slice_lines(2..5).to_string(), "This is a test 中文\nRope"); - /// assert_eq!(rope.slice_lines(3..10).to_string(), "Rope"); - /// assert_eq!(rope.slice_lines(5..10).to_string(), ""); // out of bounds - /// ``` - fn slice_lines(&self, rows_range: Range) -> RopeSlice<'_>; - - /// Return an iterator over all lines in the rope. - /// - /// Each line slice includes `\r` if present, but not `\n`. - /// - /// ``` - /// use gpui_component::{Rope, RopeExt}; - /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); - /// let lines: Vec<_> = rope.iter_lines().map(|r| r.to_string()).collect(); - /// assert_eq!(lines, vec!["Hello", "World\r", "This is a test 中文", "Rope"]); - /// ``` - fn iter_lines(&self) -> RopeLines<'_>; - - /// Return the number of lines in the rope. - /// - /// ``` - /// use gpui_component::{Rope, RopeExt}; - /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); - /// assert_eq!(rope.lines_len(), 4); - /// ``` - fn lines_len(&self) -> usize; - - /// Return the length of the row (0-based) in characters, including `\r` if present, but not `\n`. - /// - /// If the row is out of bounds, return 0. - /// - /// ``` - /// use gpui_component::{Rope, RopeExt}; - /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); - /// assert_eq!(rope.line_len(0), 5); // "Hello" - /// assert_eq!(rope.line_len(1), 6); // "World\r" - /// assert_eq!(rope.line_len(2), 21); // "This is a test 中文" - /// assert_eq!(rope.line_len(4), 0); // out of bounds - /// ``` - fn line_len(&self, row: usize) -> usize; - - /// Replace the text in the given byte range with new text. - /// - /// # Panics - /// - /// - If the range is not on char boundary. - /// - If the range is out of bounds. - /// - /// ``` - /// use gpui_component::{Rope, RopeExt}; - /// let mut rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); - /// rope.replace(6..11, "Universe"); - /// assert_eq!(rope.to_string(), "Hello\nUniverse\r\nThis is a test 中文\nRope"); - /// ``` - fn replace(&mut self, range: Range, new_text: &str); - - /// 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). - /// - /// The column is in characters. - fn position_to_offset(&self, line_col: &Position) -> usize; - - /// Get the line, column [`Position`] (0-based) from the given byte offset. - /// - /// The column is in characters. - fn offset_to_position(&self, offset: usize) -> Position; - - /// Get point (row, column) from the given byte offset. - /// - /// The column is in bytes. - fn offset_to_point(&self, offset: usize) -> Point; - - /// Get byte offset from the given point (row, column). - /// - /// The column is 0-based in bytes. - fn point_to_offset(&self, point: Point) -> usize; - - /// Get the word byte range at the given byte offset (0-based). - fn word_range(&self, offset: usize) -> Option>; - - /// Get word at the given byte offset (0-based). - fn word_at(&self, offset: usize) -> String; - - /// Convert offset in UTF-16 to byte offset (0-based). - /// - /// Runs in O(log N) time. - fn offset_utf16_to_offset(&self, offset_utf16: usize) -> usize; - - /// Convert byte offset (0-based) to offset in UTF-16. - /// - /// Runs in O(log N) time. - fn offset_to_offset_utf16(&self, offset: usize) -> usize; - - /// Get a clipped offset (avoid in a char boundary). - /// - /// - If Bias::Left and inside the char boundary, return the ix - 1; - /// - If Bias::Right and in inside char boundary, return the ix + 1; - /// - Otherwise return the ix. - /// - /// ``` - /// use gpui_component::{Rope, RopeExt}; - /// use sum_tree::Bias; - /// - /// let rope = Rope::from("Hello 中文🎉 test\nRope"); - /// assert_eq!(rope.clip_offset(5, Bias::Left), 5); - /// // Inside multi-byte character '中' (3 bytes) - /// assert_eq!(rope.clip_offset(7, Bias::Left), 6); - /// assert_eq!(rope.clip_offset(7, Bias::Right), 9); - /// ``` - fn clip_offset(&self, offset: usize, bias: Bias) -> usize; - - /// Convert offset in characters to byte offset (0-based). - /// - /// Run in O(n) time. - /// - /// # Example - /// - /// ``` - /// use gpui_component::{Rope, RopeExt}; - /// let rope = Rope::from("a 中文🎉 test\nRope"); - /// assert_eq!(rope.char_index_to_offset(0), 0); - /// assert_eq!(rope.char_index_to_offset(1), 1); - /// assert_eq!(rope.char_index_to_offset(3), "a 中".len()); - /// assert_eq!(rope.char_index_to_offset(5), "a 中文🎉".len()); - /// ``` - fn char_index_to_offset(&self, char_index: usize) -> usize; - - /// Convert byte offset (0-based) to offset in characters. - /// - /// Run in O(n) time. - /// - /// # Example - /// - /// ``` - /// use gpui_component::{Rope, RopeExt}; - /// let rope = Rope::from("a 中文🎉 test\nRope"); - /// assert_eq!(rope.offset_to_char_index(0), 0); - /// assert_eq!(rope.offset_to_char_index(1), 1); - /// assert_eq!(rope.offset_to_char_index(3), 3); - /// assert_eq!(rope.offset_to_char_index(4), 3); - /// ``` - fn offset_to_char_index(&self, offset: usize) -> usize; -} - -impl RopeExt for Rope { - fn slice_line(&self, row: usize) -> RopeSlice<'_> { - let total_lines = self.lines_len(); - if row >= total_lines { - return self.slice(0..0); - } - - let line = self.line(row, LineType::LF); - if line.len() > 0 { - let line_end = line.len() - 1; - if line.is_char_boundary(line_end) && line.char(line_end) == '\n' { - return line.slice(..line_end); - } - } - - line - } - - fn slice_lines(&self, rows_range: Range) -> RopeSlice<'_> { - let start = self.line_start_offset(rows_range.start); - let end = self.line_end_offset(rows_range.end.saturating_sub(1)); - self.slice(start..end) - } - - fn iter_lines(&self) -> RopeLines<'_> { - RopeLines::new(self) - } - - fn line_len(&self, row: usize) -> usize { - self.slice_line(row).len() - } - - fn line_start_offset(&self, row: usize) -> usize { - self.point_to_offset(Point::new(row, 0)) - } - - fn offset_to_point(&self, offset: usize) -> Point { - let offset = self.clip_offset(offset, Bias::Left); - let row = self.byte_to_line_idx(offset, LineType::LF); - let line_start = self.line_to_byte_idx(row, LineType::LF); - let column = offset.saturating_sub(line_start); - Point::new(row, column) - } - - fn point_to_offset(&self, point: Point) -> usize { - if point.row >= self.lines_len() { - return self.len(); - } - - let line_start = self.line_to_byte_idx(point.row, LineType::LF); - line_start + point.column - } - - fn position_to_offset(&self, pos: &Position) -> usize { - let line = self.slice_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.slice_line(point.row); - let offset = line.utf16_to_byte_idx(line.byte_to_utf16_idx(point.column)); - let character = line.slice(..offset).chars().count(); - Position::new(point.row as u32, character as u32) - } - - fn line_end_offset(&self, row: usize) -> usize { - if row > self.lines_len() { - return self.len(); - } - - self.line_start_offset(row) + self.line_len(row) - } - - fn lines_len(&self) -> usize { - self.len_lines(LineType::LF) - } - - fn char_at(&self, offset: usize) -> Option { - if offset > self.len() { - return None; - } - - self.get_char(offset).ok() - } - - fn word_range(&self, offset: usize) -> Option> { - if offset >= self.len() { - return None; - } - - let mut left = String::new(); - let offset = self.clip_offset(offset, Bias::Left); - for c in self.chars_at(offset).reversed() { - 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() - } - } - - #[inline] - fn offset_utf16_to_offset(&self, offset_utf16: usize) -> usize { - if offset_utf16 > self.len_utf16() { - return self.len(); - } - - self.utf16_to_byte_idx(offset_utf16) - } - - #[inline] - fn offset_to_offset_utf16(&self, offset: usize) -> usize { - if offset > self.len() { - return self.len_utf16(); - } - - self.byte_to_utf16_idx(offset) - } - - fn replace(&mut self, range: Range, new_text: &str) { - let range = - self.clip_offset(range.start, Bias::Left)..self.clip_offset(range.end, Bias::Right); - self.remove(range.clone()); - self.insert(range.start, new_text); - } - - fn clip_offset(&self, offset: usize, bias: Bias) -> usize { - if offset > self.len() { - return self.len(); - } - - if self.is_char_boundary(offset) { - return offset; - } - - if bias == Bias::Left { - self.floor_char_boundary(offset) - } else { - self.ceil_char_boundary(offset) - } - } - - fn char_index_to_offset(&self, char_offset: usize) -> usize { - self.chars().take(char_offset).map(|c| c.len_utf8()).sum() - } - - fn offset_to_char_index(&self, offset: usize) -> usize { - let offset = self.clip_offset(offset, Bias::Right); - self.slice(..offset).chars().count() - } -} diff --git a/crates/ui/src/input/selection.rs b/crates/ui/src/input/selection.rs deleted file mode 100644 index ad01817f..00000000 --- a/crates/ui/src/input/selection.rs +++ /dev/null @@ -1,140 +0,0 @@ -use std::ops::Range; - -use gpui::{Context, Window}; -use ropey::Rope; -use sum_tree::Bias; - -use crate::input::{InputState, RopeExt}; - -impl InputState { - /// Select the word at the given offset on double-click. - /// - /// The offset is the UTF-8 offset. - pub(super) fn select_word(&mut self, offset: usize, _: &mut Window, cx: &mut Context) { - let Some(range) = TextSelector::word_range(&self.text, offset) else { - return; - }; - - self.selected_range = (range.start..range.end).into(); - self.selected_word_range = Some(self.selected_range); - cx.notify() - } - - /// Select the line at the given offset on triple-click. - /// - /// The offset is the UTF-8 offset. - pub(super) fn select_line(&mut self, offset: usize, _: &mut Window, cx: &mut Context) { - let range = TextSelector::line_range(&self.text, offset); - self.selected_range = (range.start..range.end).into(); - self.selected_word_range = None; - cx.notify() - } -} - -struct TextSelector; -impl TextSelector { - /// Select a line in the given text at the specified offset. - /// - /// The offset is the UTF-8 offset. - /// - /// Returns the start and end offsets of the selected line. - pub fn line_range(text: &Rope, offset: usize) -> Range { - let offset = text.clip_offset(offset, Bias::Left); - let row = text.offset_to_point(offset).row; - let start = text.line_start_offset(row); - let end = text.line_end_offset(row); - - start..end - } - - /// Select a word in the given text at the specified offset. - /// - /// The offset is the UTF-8 offset. - /// - /// Returns the start and end offsets of the selected word. - pub fn word_range(text: &Rope, offset: usize) -> Option> { - let offset = text.clip_offset(offset, Bias::Left); - let char = text.char_at(offset)?; - let end = offset + char.len_utf8(); - let prev_chars = text.chars_at(offset).reversed().take(128); - let next_chars = text.chars_at(end).take(128); - - Some(word_range_from_chars(offset, char, prev_chars, next_chars)) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum CharType { - /// a-z, A-Z, 0-9, _ - Word, - /// '\t', ' ', '\u{00A0}' etc. - Whitespace, - /// \n, \r - Newline, - /// . , ; : ( ) [ ] { } ... or CJK characters: `汉`, `🎉` etc. - Other, -} - -impl From for CharType { - fn from(c: char) -> Self { - match c { - c if is_word_char(c) => CharType::Word, - c if c == '\n' || c == '\r' => CharType::Newline, - c if c.is_whitespace() => CharType::Whitespace, - _ => CharType::Other, - } - } -} - -impl CharType { - fn is_connectable(self, c: char) -> bool { - matches!( - (self, CharType::from(c)), - (CharType::Word, CharType::Word) | (CharType::Whitespace, CharType::Whitespace) - ) - } -} - -fn is_word_char(c: char) -> bool { - matches!(c, '_') - // ASCII alphanumeric characters, for English, numbers: `Hello123`, etc. - || c.is_ascii_alphanumeric() - // Latin script in Unicode for French, German, Spanish, etc. - || matches!(c, '\u{00C0}'..='\u{00FF}') - || matches!(c, '\u{0100}'..='\u{017F}') - || matches!(c, '\u{0180}'..='\u{024F}') - // Cyrillic for Russian, Ukrainian, etc. - || matches!(c, '\u{0400}'..='\u{04FF}') - // Vietnamese - || matches!(c, '\u{1E00}'..='\u{1EFF}') - || matches!(c, '\u{0300}'..='\u{036F}') -} - -pub(crate) fn word_range_from_chars( - offset: usize, - c: char, - prev_chars: impl Iterator, - next_chars: impl Iterator, -) -> Range { - let char_type = CharType::from(c); - let mut start = offset; - let mut end = offset + c.len_utf8(); - - for prev in prev_chars.take(128) { - if char_type.is_connectable(prev) { - start -= prev.len_utf8(); - } else { - break; - } - } - - for next in next_chars.take(128) { - if char_type.is_connectable(next) { - end += next.len_utf8(); - } else { - break; - } - } - - start..end -} diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs deleted file mode 100644 index 429ac861..00000000 --- a/crates/ui/src/input/state.rs +++ /dev/null @@ -1,2085 +0,0 @@ -//! A text input field that allows the user to enter text. -//! -//! Based on the `Input` example from the `gpui` crate. -//! https://github.com/zed-industries/zed/blob/main/crates/gpui/examples/input.rs -use std::cell::Cell; -use std::ops::Range; -use std::rc::Rc; - -use gpui::prelude::FluentBuilder as _; -use gpui::{ - Action, App, AppContext, Bounds, ClipboardItem, Context, Edges, Entity, EntityInputHandler, - EventEmitter, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, KeyBinding, - KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, - Pixels, Point, Render, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, Styled as _, - Subscription, TextAlign, UTF16Selection, Window, actions, div, point, px, -}; -use ropey::{Rope, RopeSlice}; -use serde::Deserialize; -use sum_tree::Bias; -use unicode_segmentation::*; - -use super::blink_cursor::BlinkCursor; -use super::change::Change; -use super::element::{EditorScrollbarSnapshot, TextElement}; -use super::mask_pattern::MaskPattern; -use super::mode::InputMode; -use super::{DisplayMap, MASK_CHAR}; -use crate::actions::{SelectDown, SelectLeft, SelectRight, SelectUp}; -use crate::history::History; -use crate::input::blink_cursor::CURSOR_WIDTH; -use crate::input::display_map::LineLayout; -use crate::input::element::RIGHT_MARGIN; -use crate::input::movement::MoveDirection; -use crate::input::rope_ext::Position; -use crate::input::{RopeExt as _, Selection}; -use crate::{Root, Size}; - -#[derive(Action, Clone, PartialEq, Eq, Deserialize)] -#[action(namespace = input, no_json)] -pub struct Enter { - /// Is confirm with secondary. - pub secondary: bool, - /// Whether the Shift modifier was held when Enter was pressed. - pub shift: bool, -} - -impl Enter { - /// Returns true if `action` is a primary `Enter` action (`secondary: false`), - /// regardless of whether Shift was held. - pub fn is_primary(action: &dyn Action) -> bool { - action.partial_eq(&Enter { - secondary: false, - shift: false, - }) || action.partial_eq(&Enter { - secondary: false, - shift: true, - }) - } -} - -actions!( - input, - [ - Backspace, - Delete, - DeleteToBeginningOfLine, - DeleteToEndOfLine, - DeleteToPreviousWordStart, - DeleteToNextWordEnd, - Indent, - Outdent, - IndentInline, - OutdentInline, - MoveUp, - MoveDown, - MoveLeft, - MoveRight, - MoveHome, - MoveEnd, - MovePageUp, - MovePageDown, - SelectAll, - SelectToStartOfLine, - SelectToEndOfLine, - SelectToStart, - SelectToEnd, - SelectToPreviousWordStart, - SelectToNextWordEnd, - ShowCharacterPalette, - Copy, - Cut, - Paste, - Undo, - Redo, - MoveToStartOfLine, - MoveToEndOfLine, - MoveToStart, - MoveToEnd, - MoveToPreviousWord, - MoveToNextWord, - Escape, - ] -); - -#[derive(Clone)] -pub enum InputEvent { - Change, - PressEnter { secondary: bool, shift: bool }, - Focus, - Blur, -} - -pub(super) const CONTEXT: &str = "Input"; - -pub(crate) fn init(cx: &mut App) { - cx.bind_keys([ - KeyBinding::new("backspace", Backspace, Some(CONTEXT)), - KeyBinding::new("shift-backspace", Backspace, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("ctrl-backspace", Backspace, Some(CONTEXT)), - KeyBinding::new("delete", Delete, Some(CONTEXT)), - KeyBinding::new("shift-delete", Delete, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-backspace", DeleteToBeginningOfLine, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-delete", DeleteToEndOfLine, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("alt-backspace", DeleteToPreviousWordStart, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-backspace", DeleteToPreviousWordStart, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("alt-delete", DeleteToNextWordEnd, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-delete", DeleteToNextWordEnd, Some(CONTEXT)), - KeyBinding::new( - "enter", - Enter { - secondary: false, - shift: false, - }, - Some(CONTEXT), - ), - KeyBinding::new( - "shift-enter", - Enter { - secondary: false, - shift: true, - }, - Some(CONTEXT), - ), - KeyBinding::new( - "secondary-enter", - Enter { - secondary: true, - shift: false, - }, - Some(CONTEXT), - ), - KeyBinding::new("escape", Escape, 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", MoveHome, Some(CONTEXT)), - KeyBinding::new("end", MoveEnd, Some(CONTEXT)), - KeyBinding::new("shift-home", SelectToStartOfLine, Some(CONTEXT)), - KeyBinding::new("shift-end", SelectToEndOfLine, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("ctrl-shift-a", SelectToStartOfLine, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("ctrl-shift-e", SelectToEndOfLine, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("shift-cmd-left", SelectToStartOfLine, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("shift-cmd-right", SelectToEndOfLine, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("alt-shift-left", SelectToPreviousWordStart, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-shift-left", SelectToPreviousWordStart, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("alt-shift-right", SelectToNextWordEnd, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-shift-right", SelectToNextWordEnd, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("ctrl-cmd-space", ShowCharacterPalette, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-a", SelectAll, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-a", SelectAll, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-c", Copy, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-c", Copy, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-x", Cut, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-x", Cut, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-v", Paste, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-v", Paste, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("ctrl-a", MoveHome, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-left", MoveHome, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("ctrl-e", MoveEnd, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-right", MoveEnd, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-z", Undo, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-shift-z", Redo, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-up", MoveToStart, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-down", MoveToEnd, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("alt-left", MoveToPreviousWord, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("alt-right", MoveToNextWord, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-left", MoveToPreviousWord, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-right", MoveToNextWord, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-shift-up", SelectToStart, Some(CONTEXT)), - #[cfg(target_os = "macos")] - KeyBinding::new("cmd-shift-down", SelectToEnd, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-z", Undo, Some(CONTEXT)), - #[cfg(not(target_os = "macos"))] - KeyBinding::new("ctrl-y", Redo, Some(CONTEXT)), - ]); -} - -/// Whitespace indicators for rendering spaces and tabs. -#[derive(Clone, Default)] -pub(crate) struct WhitespaceIndicators { - /// Shaped line for space character indicator (•) - pub(crate) space: ShapedLine, - /// Shaped line for tab character indicator (→) - pub(crate) tab: ShapedLine, -} - -#[derive(Clone)] -pub(super) struct LastLayout { - /// The visible range (no wrap) of lines in the viewport, the value is row (0-based) index. - /// This is the buffer line range that encompasses all visible lines. - pub(super) visible_range: Range, - /// The list of visible buffer line indices (excludes hidden/folded lines). - /// Parallel to `lines`: `visible_buffer_lines[i]` is the buffer line index of `lines[i]`. - pub(super) visible_buffer_lines: Vec, - /// Byte offset of each visible buffer line in the Rope (parallel to visible_buffer_lines/lines). - pub(super) visible_line_byte_offsets: Vec, - /// 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, no empty entries for hidden lines). - pub(super) lines: Rc>, - /// The line_height of text layout, this will change will InputElement painted. - pub(super) line_height: Pixels, - /// 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>, - /// The text align of the text layout. - pub(super) text_align: TextAlign, - /// The content width of the text layout. - pub(super) content_width: Pixels, -} - -impl LastLayout { - /// Get the line layout for the given buffer row (0-based). - /// - /// Uses binary search on `visible_buffer_lines` to find the line. - /// Returns None if the row is not visible (out of range or folded). - pub(crate) fn line(&self, row: usize) -> Option<&LineLayout> { - let pos = self.visible_buffer_lines.binary_search(&row).ok()?; - self.lines.get(pos) - } - - /// Get the alignment offset for the given line width. - pub(super) fn alignment_offset(&self, line_width: Pixels) -> Pixels { - match self.text_align { - TextAlign::Left => px(0.), - TextAlign::Center => (self.content_width - line_width).half().max(px(0.)), - TextAlign::Right => (self.content_width - line_width).max(px(0.)), - } - } -} - -/// InputState to keep editing state of the [`super::Input`]. -#[allow(clippy::type_complexity)] -pub struct InputState { - pub(super) focus_handle: FocusHandle, - pub(super) mode: InputMode, - pub(super) text: Rope, - pub(super) display_map: DisplayMap, - pub(super) history: History, - pub(super) blink_cursor: Entity, - pub loading: bool, - /// Range in UTF-8 length for the selected text. - /// - /// - "Hello 世界💝" = 16 - /// - "💝" = 4 - pub(super) selected_range: Selection, - pub(super) replaceable: bool, - /// Range for save the selected word, use to keep word range when drag move. - pub(super) selected_word_range: Option, - pub(super) selection_reversed: bool, - /// The marked range is the temporary insert text on IME typing. - pub(super) ime_marked_range: Option, - pub(super) last_layout: 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) selecting: bool, - pub(super) size: Size, - pub(super) disabled: bool, - pub(super) masked: bool, - pub(super) clean_on_escape: bool, - pub(super) submit_on_enter: bool, - pub(super) soft_wrap: bool, - /// This flag tells the renderer to prefer the end of the current visual line. - pub(crate) cursor_line_end_affinity: bool, - pub(super) pattern: Option, - pub(super) validate: Option) -> bool + 'static>>, - pub(crate) scroll_handle: ScrollHandle, - /// The deferred scroll offset to apply on next layout. - pub(crate) deferred_scroll_offset: Option>, - /// The size of the scrollable content. - pub(crate) scroll_size: gpui::Size, - pub(super) editor_scrollbar_paddings: Cell>, - pub(super) editor_scrollbar_snapshot: Cell>, - pub(super) text_align: TextAlign, - - /// 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, - /// A flag to indicate if we should emit InputEvents. - pub(super) emit_events: bool, - - /// To remember the horizontal column (x-coordinate) of the cursor position for keep column for move up/down. - /// - /// The first element is the x-coordinate (Pixels), preferred to use this. - /// The second element is the column (usize), fallback to use this. - pub(super) preferred_column: Option<(Pixels, usize)>, - _subscriptions: Vec, -} - -impl EventEmitter for InputState {} - -impl InputState { - /// Create a Input state with default [`InputMode::SingleLine`] mode. - /// - /// See also: [`Self::multi_line`], [`Self::auto_grow`] to set other mode. - pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let focus_handle = cx.focus_handle().tab_stop(true); - let blink_cursor = cx.new(|_| BlinkCursor::new()); - let history = History::new().group_interval(instant::Duration::from_secs(1)); - - let _subscriptions = vec![ - // Observe the blink cursor to repaint the view when it changes. - cx.observe(&blink_cursor, |_, _, cx| cx.notify()), - // Blink the cursor when the window is active, pause when it's not. - cx.observe_window_activation(window, |input, window, cx| { - if window.is_window_active() { - let focus_handle = input.focus_handle.clone(); - if focus_handle.is_focused(window) { - input.blink_cursor.update(cx, |blink_cursor, cx| { - blink_cursor.start(cx); - }); - } - } - }), - cx.on_focus(&focus_handle, window, Self::on_focus), - cx.on_blur(&focus_handle, window, Self::on_blur), - ]; - - let text_style = window.text_style(); - - Self { - focus_handle: focus_handle.clone(), - text: "".into(), - display_map: DisplayMap::new(text_style.font(), window.rem_size(), None), - blink_cursor, - history, - selected_range: Selection::default(), - replaceable: true, - selected_word_range: None, - selection_reversed: false, - ime_marked_range: None, - input_bounds: Bounds::default(), - selecting: false, - disabled: false, - masked: false, - clean_on_escape: false, - submit_on_enter: false, - soft_wrap: true, - loading: false, - pattern: None, - validate: None, - mode: InputMode::default(), - last_layout: None, - last_bounds: None, - last_selected_range: None, - last_cursor: None, - scroll_handle: ScrollHandle::new(), - scroll_size: gpui::size(px(0.), px(0.)), - editor_scrollbar_paddings: Cell::new(Edges { - top: px(0.), - right: px(0.), - bottom: px(0.), - left: px(0.), - }), - editor_scrollbar_snapshot: Cell::new(None), - deferred_scroll_offset: None, - preferred_column: None, - placeholder: SharedString::default(), - mask_pattern: MaskPattern::default(), - text_align: TextAlign::Left, - silent_replace_text: false, - emit_events: true, - size: Size::default(), - _subscriptions, - cursor_line_end_affinity: false, - } - } - - /// Set Input to use multi line mode. - /// - /// Default rows is 2. - pub fn multi_line(mut self, multi_line: bool) -> Self { - self.mode = self.mode.multi_line(multi_line); - self - } - - /// Set Input to use [`InputMode::AutoGrow`] mode with min, max rows limit. - pub fn auto_grow(mut self, min_rows: usize, max_rows: usize) -> Self { - self.mode = InputMode::auto_grow(min_rows, max_rows); - self - } - - /// Set whether search UI allows replacement, default is true. - pub fn replaceable(mut self, allow: bool) -> Self { - self.replaceable = allow; - self - } - - /// Set placeholder - pub fn placeholder(mut self, placeholder: impl Into) -> Self { - self.placeholder = placeholder.into(); - 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::PlainText { 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, - placeholder: impl Into, - _: &mut Window, - cx: &mut Context, - ) { - self.placeholder = placeholder.into(); - cx.notify(); - } - - /// Find which line and sub-line the given offset belongs to, along with the position within that sub-line. - /// - /// Returns: - /// - /// - 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. - pub(super) fn line_and_position_for_offset( - &self, - offset: usize, - ) -> (usize, usize, Option>) { - let Some(last_layout) = &self.last_layout else { - return (0, 0, None); - }; - let line_height = last_layout.line_height; - - let mut y_offset = last_layout.visible_top; - for (vi, line) in last_layout.lines.iter().enumerate() { - let prev_lines_offset = last_layout.visible_line_byte_offsets[vi]; - let local_offset = offset.saturating_sub(prev_lines_offset); - if let Some(pos) = line.position_for_index(local_offset, last_layout, false) { - let sub_line_index = (pos.y / line_height) as usize; - let adjusted_pos = point(pos.x + last_layout.line_number_width, pos.y + y_offset); - return (vi, sub_line_index, Some(adjusted_pos)); - } - - y_offset += line.size(line_height).height; - } - (0, 0, None) - } - - /// Set the text of the input field. - /// - /// And the selection_range will be reset to 0..0. - pub fn set_value( - &mut self, - value: impl Into, - window: &mut Window, - cx: &mut Context, - ) { - self.history.ignore = true; - self.emit_events = false; - self.replace_text(value, window, cx); - self.history.ignore = false; - self.emit_events = true; - - // Ensure cursor to start when set text - 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.))); - - self.history.clear(); - cx.notify(); - } - - /// Insert text at the current cursor position. - /// - /// And the cursor will be moved to the end of inserted text. - pub fn insert( - &mut self, - text: impl Into, - window: &mut Window, - cx: &mut Context, - ) { - let was_disabled = self.disabled; - self.disabled = false; - let text: SharedString = text.into(); - 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(); - self.disabled = was_disabled; - } - - /// Replace text at the current cursor position. - /// - /// And the cursor will be moved to the end of replaced text. - pub fn replace( - &mut self, - text: impl Into, - window: &mut Window, - cx: &mut Context, - ) { - let was_disabled = self.disabled; - self.disabled = false; - let text: SharedString = text.into(); - self.replace_text_in_range_silent(None, &text, window, cx); - self.selected_range = (self.selected_range.end..self.selected_range.end).into(); - self.disabled = was_disabled; - } - - fn replace_text( - &mut self, - text: impl Into, - window: &mut Window, - cx: &mut Context, - ) { - let was_disabled = self.disabled; - self.disabled = false; - let text: SharedString = text.into(); - let range = 0..self.text.chars().map(|c| c.len_utf16()).sum(); - self.replace_text_in_range_silent(Some(range), &text, window, cx); - self.disabled = was_disabled; - } - - /// Set with disabled mode. - /// - /// See also: [`Self::set_disabled`], [`Self::is_disabled`]. - #[allow(unused)] - pub(crate) fn disabled(mut self, disabled: bool) -> Self { - self.disabled = disabled; - self - } - - /// Set with password masked state. - /// - /// Only for [`InputMode::SingleLine`] mode. - pub fn masked(mut self, masked: bool) -> Self { - debug_assert!(self.mode.is_single_line()); - self.masked = masked; - self - } - - /// Set the password masked state of the input field. - /// - /// Only for [`InputMode::SingleLine`] mode. - pub fn set_masked(&mut self, masked: bool, _: &mut Window, cx: &mut Context) { - debug_assert!(self.mode.is_single_line()); - self.masked = masked; - cx.notify(); - } - - /// Set true to clear the input by pressing Escape key. - pub fn clean_on_escape(mut self) -> Self { - self.clean_on_escape = true; - self - } - - /// Set true to treat `Enter` as a submit action in multi-line mode, - /// while `Shift+Enter` inserts a newline. - /// - /// Default is `false` (both `Enter` and `Shift+Enter` insert a newline). - pub fn submit_on_enter(mut self, submit: bool) -> Self { - self.submit_on_enter = submit; - self - } - - /// Set the soft wrap mode for multi-line input, default is true. - pub fn soft_wrap(mut self, wrap: bool) -> Self { - debug_assert!(self.mode.is_multi_line()); - self.soft_wrap = wrap; - self - } - - /// Update the soft wrap mode for multi-line input, default is true. - pub fn set_soft_wrap(&mut self, wrap: bool, _: &mut Window, cx: &mut Context) { - debug_assert!(self.mode.is_multi_line()); - self.soft_wrap = wrap; - if wrap { - let wrap_width = self - .last_layout - .as_ref() - .and_then(|b| b.wrap_width) - .unwrap_or(self.input_bounds.size.width); - - self.display_map.on_layout_changed(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.display_map.on_layout_changed(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 { - debug_assert!(self.mode.is_single_line()); - self.pattern = Some(pattern); - self - } - - /// Set the regular expression pattern of the input field with reference. - /// - /// Only for [`InputMode::SingleLine`] mode. - pub fn set_pattern( - &mut self, - pattern: regex::Regex, - _window: &mut Window, - _cx: &mut Context, - ) { - debug_assert!(self.mode.is_single_line()); - self.pattern = Some(pattern); - } - - /// Set the validation function of the input field. - /// - /// Only for [`InputMode::SingleLine`] mode. - pub fn validate(mut self, f: impl Fn(&str, &mut Context) -> bool + 'static) -> Self { - debug_assert!(self.mode.is_single_line()); - self.validate = Some(Box::new(f)); - self - } - - /// Set true to show spinner at the input right. - /// - /// Only for [`InputMode::SingleLine`] mode. - pub fn set_loading(&mut self, loading: bool, cx: &mut Context) { - debug_assert!(self.mode.is_single_line()); - self.loading = loading; - cx.notify(); - } - - /// Set the default value of the input field. - pub fn default_value(mut self, value: impl Into) -> Self { - let text: SharedString = value.into(); - self.text = Rope::from(text.as_str()); - self - } - - /// Return the value of the input field. - pub fn value(&self) -> SharedString { - SharedString::new(self.text.to_string()) - } - - /// Return the portion of the value within the input field that - /// is selected by the user - pub fn selected_value(&self) -> SharedString { - SharedString::new(self.selected_text().to_string()) - } - - /// Return the value without mask. - pub fn unmask_value(&self) -> SharedString { - 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 offset = self.text.position_to_offset(&position); - - self.move_to(offset, None, cx); - self.update_preferred_column(); - self.focus(window, cx); - } - - /// Focus the input field. - pub fn focus(&self, window: &mut Window, cx: &mut Context) { - self.focus_handle.focus(window, cx); - self.blink_cursor.update(cx, |cursor, cx| { - cursor.start(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 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 select_to_start( - &mut self, - _: &SelectToStart, - _: &mut Window, - cx: &mut Context, - ) { - self.select_to(0, cx); - } - - pub(super) fn select_to_end( - &mut self, - _: &SelectToEnd, - _: &mut Window, - cx: &mut Context, - ) { - let end = self.text.len(); - self.select_to(end, cx); - } - - pub(super) fn select_to_start_of_line( - &mut self, - _: &SelectToStartOfLine, - _: &mut Window, - cx: &mut Context, - ) { - let offset = self.start_of_line(); - self.select_to(offset, cx); - } - - pub(super) fn select_to_end_of_line( - &mut self, - _: &SelectToEndOfLine, - _: &mut Window, - cx: &mut Context, - ) { - let offset = self.end_of_line(); - self.select_to(offset, cx); - } - - pub(super) fn select_to_previous_word( - &mut self, - _: &SelectToPreviousWordStart, - _: &mut Window, - cx: &mut Context, - ) { - let offset = self.previous_start_of_word(); - self.select_to(offset, cx); - } - - pub(super) fn select_to_next_word( - &mut self, - _: &SelectToNextWordEnd, - _: &mut Window, - cx: &mut Context, - ) { - let offset = self.next_end_of_word(); - self.select_to(offset, cx); - } - - /// Return the start offset of the previous word. - pub(super) fn previous_start_of_word(&mut self) -> usize { - let offset = self.selected_range.start; - let offset = self.offset_from_utf16(self.offset_to_utf16(offset)); - // FIXME: Avoid to_string - let left_part = self.text.slice(0..offset).to_string(); - - UnicodeSegmentation::split_word_bound_indices(left_part.as_str()) - .rfind(|(_, s)| !s.trim_start().is_empty()) - .map(|(i, _)| i) - .unwrap_or(0) - } - - /// Return the next end offset of the next word. - pub(super) fn next_end_of_word(&mut self) -> usize { - let offset = self.cursor(); - let offset = self.offset_from_utf16(self.offset_to_utf16(offset)); - let right_part = self.text.slice(offset..self.text.len()).to_string(); - - UnicodeSegmentation::split_word_bound_indices(right_part.as_str()) - .find(|(_, s)| !s.trim_start().is_empty()) - .map(|(i, s)| offset + i + s.len()) - .unwrap_or(self.text.len()) - } - - /// Get start of line byte offset of cursor. - /// - /// When soft wrap is active, first press goes to visual line start, - /// second press (already at visual start) goes to logical line start. - pub(super) fn start_of_line(&self) -> usize { - if self.mode.is_single_line() { - return 0; - } - - let row = self.text.offset_to_point(self.cursor()).row; - let logical_start = self.text.line_start_offset(row); - - if self.soft_wrap { - let wrap_point = self.display_map.offset_to_wrap_display_point(self.cursor()); - if let Some(line) = self.display_map.lines().get(row) - && let Some(range) = line.wrapped_lines.get(wrap_point.local_row) - { - let visual_start = logical_start + range.start; - if self.cursor() != visual_start { - return visual_start; - } - } - } - - logical_start - } - - /// Get end of line byte offset of cursor. - /// - /// When soft wrap is active, first press goes to visual line end, - /// second press (already at visual end) goes to logical line end. - pub(super) 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; - let logical_start = self.text.line_start_offset(row); - let logical_end = self.text.line_end_offset(row); - - if self.soft_wrap { - let wrap_point = self.display_map.offset_to_wrap_display_point(self.cursor()); - if let Some(line) = self.display_map.lines().get(row) - && let Some(range) = line.wrapped_lines.get(wrap_point.local_row) - { - let visual_end = logical_start + range.end; - if self.cursor() != visual_end { - return visual_end; - } - } - } - - logical_end - } - - /// Get start line of selection start or end (The min value). - /// - /// This is means is always get the first line of selection. - pub(super) 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() - .rfind('\n') - .map(|i| i + 1) - .unwrap_or(0) - } - - /// Get indent string of next line. - /// - /// To get current and next line indent, to return more depth one. - pub(super) fn indent_of_next_line(&mut self) -> String { - if self.mode.is_single_line() { - return "".into(); - } - - let mut current_indent = String::new(); - let mut next_indent = String::new(); - let current_line_start_pos = self.start_of_line(); - let next_line_start_pos = self.end_of_line(); - for c in self.text.slice(current_line_start_pos..).chars() { - if !c.is_whitespace() { - break; - } - if c == '\n' || c == '\r' { - break; - } - current_indent.push(c); - } - - for c in self.text.slice(next_line_start_pos..).chars() { - if !c.is_whitespace() { - break; - } - if c == '\n' || c == '\r' { - break; - } - next_indent.push(c); - } - - if next_indent.len() > current_indent.len() { - next_indent - } else { - current_indent - } - } - - 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()), cx) - } - self.replace_text_in_range(None, "", window, cx); - self.pause_blink_cursor(cx); - } - - 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()), cx) - } - self.replace_text_in_range(None, "", window, cx); - self.pause_blink_cursor(cx); - } - - pub(super) fn delete_to_beginning_of_line( - &mut self, - _: &DeleteToBeginningOfLine, - window: &mut Window, - cx: &mut Context, - ) { - if !self.selected_range.is_empty() { - self.replace_text_in_range(None, "", window, cx); - self.pause_blink_cursor(cx); - return; - } - - let mut offset = self.start_of_line(); - if offset == self.cursor() { - offset = offset.saturating_sub(1); - } - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(offset..self.cursor()))), - "", - window, - cx, - ); - self.pause_blink_cursor(cx); - } - - pub(super) fn delete_to_end_of_line( - &mut self, - _: &DeleteToEndOfLine, - window: &mut Window, - cx: &mut Context, - ) { - if !self.selected_range.is_empty() { - self.replace_text_in_range(None, "", window, cx); - self.pause_blink_cursor(cx); - return; - } - - let mut offset = self.end_of_line(); - if offset == self.cursor() { - offset = (offset + 1).clamp(0, self.text.len()); - } - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(self.cursor()..offset))), - "", - window, - cx, - ); - self.pause_blink_cursor(cx); - } - - pub(super) fn delete_previous_word( - &mut self, - _: &DeleteToPreviousWordStart, - window: &mut Window, - cx: &mut Context, - ) { - if !self.selected_range.is_empty() { - self.replace_text_in_range(None, "", window, cx); - self.pause_blink_cursor(cx); - return; - } - - let offset = self.previous_start_of_word(); - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(offset..self.cursor()))), - "", - window, - cx, - ); - self.pause_blink_cursor(cx); - } - - pub(super) fn delete_next_word( - &mut self, - _: &DeleteToNextWordEnd, - window: &mut Window, - cx: &mut Context, - ) { - if !self.selected_range.is_empty() { - self.replace_text_in_range(None, "", window, cx); - self.pause_blink_cursor(cx); - return; - } - - let offset = self.next_end_of_word(); - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(self.cursor()..offset))), - "", - window, - cx, - ); - self.pause_blink_cursor(cx); - } - - pub(super) fn enter(&mut self, action: &Enter, window: &mut Window, cx: &mut Context) { - // In multi-line mode with `submit_on_enter` enabled, a plain `Enter` - // (without Shift) is treated as submit: propagate the action and emit - // PressEnter without inserting a newline. `Shift+Enter` still inserts - // a newline. - let insert_newline = self.mode.is_multi_line() && (!self.submit_on_enter || action.shift); - - if insert_newline { - // Get current line indent - let indent = if self.mode.is_indentable() { - self.indent_of_next_line() - } else { - "".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 or submit-on-enter: just emit the event - // (e.g.: in a dialog to confirm, or a chat textarea to send). - cx.propagate(); - } - - cx.emit(InputEvent::PressEnter { - secondary: action.secondary, - shift: action.shift, - }); - } - - pub(super) fn clean(&mut self, window: &mut Window, cx: &mut Context) { - self.replace_text("", window, cx); - self.selected_range = (0..0).into(); - self.scroll_to(0, None, cx); - } - - 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.clean_on_escape { - return self.clean(window, cx); - } - - cx.propagate(); - } - - pub(super) fn on_mouse_down( - &mut self, - event: &MouseDownEvent, - window: &mut Window, - cx: &mut Context, - ) { - // If there have IME marked range and is empty (Means pressed Esc to abort IME typing) - // Clear the marked range. - if let Some(ime_marked_range) = &self.ime_marked_range - && ime_marked_range.is_empty() - { - self.ime_marked_range = None; - } - - self.selecting = true; - let offset = self.index_for_mouse_position(event.position); - - // Triple click to select line - if event.button == MouseButton::Left && event.click_count >= 3 { - self.select_line(offset, window, cx); - return; - } - - // Double click to select word - if event.button == MouseButton::Left && event.click_count == 2 { - self.select_word(offset, window, cx); - return; - } - - if event.modifiers.shift { - self.select_to(offset, cx); - } else { - self.move_to(offset, None, cx) - } - } - - pub(super) fn on_mouse_up( - &mut self, - _: &MouseUpEvent, - _window: &mut Window, - _cx: &mut Context, - ) { - if self.selected_range.is_empty() { - self.selection_reversed = false; - } - self.selecting = false; - self.selected_word_range = None; - } - - pub(super) fn on_scroll_wheel( - &mut self, - event: &ScrollWheelEvent, - window: &mut Window, - cx: &mut Context, - ) { - let line_height = self - .last_layout - .as_ref() - .map(|layout| layout.line_height) - .unwrap_or(window.line_height()); - let delta = event.delta.pixel_delta(line_height); - - let old_offset = self.scroll_handle.offset(); - self.update_scroll_offset(Some(old_offset + delta), cx); - - // Only stop propagation if the offset actually changed - if self.scroll_handle.offset() != old_offset { - cx.stop_propagation(); - } - } - - pub(super) fn update_scroll_offset( - &mut self, - offset: Option>, - cx: &mut Context, - ) { - let mut offset = offset.unwrap_or(self.scroll_handle.offset()); - // In addition to left alignment, a cursor position will be reserved on the right side - let safe_x_offset = if self.text_align == TextAlign::Left { - px(0.) - } else { - -CURSOR_WIDTH - }; - - let safe_y_range = - (-self.scroll_size.height + self.input_bounds.size.height).min(px(0.0))..px(0.); - let safe_x_range = (-self.scroll_size.width + self.input_bounds.size.width + safe_x_offset) - .min(safe_x_offset)..px(0.); - - 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(); - } - - /// Scroll to make the given offset visible. - /// - /// If `direction` is Some, will keep edges at the same side. - pub(crate) fn scroll_to( - &mut self, - offset: usize, - direction: Option, - 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 was_offset = scroll_offset; - let line_height = last_layout.line_height; - - let point = self.text.offset_to_point(offset); - - let row = point.row; - - let mut row_offset_y = px(0.); - for (ix, _wrap_line) in self.display_map.lines().iter().enumerate() { - if ix == row { - break; - } - - // Only accumulate height for visible (non-folded) wrap rows - let visible_wrap_rows = self.display_map.visible_wrap_row_count_for_buffer_line(ix); - row_offset_y += line_height * visible_wrap_rows; - } - - // For Right alignment use 0 margin: the cursor indicator is clamped inside bounds - // in layout_cursor, so shifting the text here would cause a first-click visual jump. - let safety_margin = match last_layout.text_align { - TextAlign::Left => RIGHT_MARGIN, - TextAlign::Right => px(0.), - TextAlign::Center => CURSOR_WIDTH, - }; - if let Some(line) = last_layout - .lines - .get(row.saturating_sub(last_layout.visible_range.start)) - { - // Check to scroll horizontally and soft wrap lines - if let Some(pos) = line.position_for_index(point.column, last_layout, false) { - let bounds_width = bounds.size.width - last_layout.line_number_width; - let col_offset_x = pos.x; - row_offset_y += pos.y; - if col_offset_x - safety_margin < -scroll_offset.x { - // If the position is out of the visible area, scroll to make it visible - scroll_offset.x = -col_offset_x + safety_margin; - } else if col_offset_x + safety_margin > -scroll_offset.x + bounds_width { - scroll_offset.x = -(col_offset_x - bounds_width + safety_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 = line_height; - if row_offset_y - edge_height + line_height < -scroll_offset.y { - // Scroll up - scroll_offset.y = -row_offset_y + edge_height - line_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); - } - - // Avoid necessary scroll, when it was already in the correct position. - if direction == Some(MoveDirection::Up) { - scroll_offset.y = scroll_offset.y.max(was_offset.y); - } else if direction == Some(MoveDirection::Down) { - scroll_offset.y = scroll_offset.y.min(was_offset.y); - } - - 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, - window: &mut Window, - _: &mut Context, - ) { - window.show_character_palette(); - } - - pub(super) fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { - if self.selected_range.is_empty() { - return; - } - - let selected_text = self.text.slice(self.selected_range).to_string(); - cx.write_to_clipboard(ClipboardItem::new_string(selected_text)); - } - - pub(super) fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context) { - if self.selected_range.is_empty() { - return; - } - - let selected_text = self.text.slice(self.selected_range).to_string(); - cx.write_to_clipboard(ClipboardItem::new_string(selected_text)); - - 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() { - let mut new_text = clipboard.text().unwrap_or_default(); - if !self.mode.is_multi_line() { - new_text = new_text.replace('\n', ""); - } - - self.replace_text_in_range_silent(None, &new_text, window, cx); - self.scroll_to(self.cursor(), None, cx); - } - } - - fn push_history(&mut self, text: &Rope, range: &Range, new_text: &str) { - if self.history.ignore { - return; - } - - let range = - text.clip_offset(range.start, Bias::Left)..text.clip_offset(range.end, Bias::Right); - 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, &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.into()); - self.replace_text_in_range_silent(Some(range_utf16), &change.old_text, window, cx); - } - } - self.history.ignore = false; - } - - pub(super) fn redo(&mut self, _: &Redo, window: &mut Window, cx: &mut Context) { - 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.into()); - self.replace_text_in_range_silent(Some(range_utf16), &change.new_text, window, cx); - } - } - self.history.ignore = false; - } - - /// 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 { - self.selected_range.start - } else { - self.selected_range.end - } - } - - /// Visible row range in the last laid-out viewport, `None` before first layout. - pub fn visible_row_range(&self) -> Option> { - self.last_layout.as_ref().map(|l| l.visible_range.clone()) - } - - /// Current scroll offset of the editor viewport. - pub fn scroll_offset(&self) -> gpui::Point { - self.scroll_handle.offset() - } - - /// Laid-out line height; `None` before first layout. - pub fn line_height(&self) -> Option { - self.last_layout.as_ref().map(|l| l.line_height) - } - - /// Returns the current selection as a byte range into the text. - /// - /// The range is empty (`start == end`) when no text is selected; in - /// that case the offset equals `cursor()`. Byte offsets are measured - /// in the underlying rope's byte units. - pub fn selected_range(&self) -> std::ops::Range { - self.selected_range.into() - } - - pub(crate) fn index_for_mouse_position(&self, position: Point) -> usize { - // If the text is empty, always return 0 - if self.text.len() == 0 { - return 0; - } - - let (Some(bounds), Some(last_layout)) = - (self.last_bounds.as_ref(), self.last_layout.as_ref()) - else { - return 0; - }; - - let line_height = last_layout.line_height; - let line_number_width = last_layout.line_number_width; - - // TIP: About the IBeam cursor - // - // If cursor style is IBeam, the mouse mouse position is in the middle of the cursor (This is special in OS) - - // The position is relative to the bounds of the text input - // - // bounds.origin: - // - // - included the input padding. - // - included the scroll offset. - let inner_position = position - bounds.origin - point(line_number_width, px(0.)); - - let mut y_offset = last_layout.visible_top; - - // Traverse visible buffer lines (compact, no hidden entries) - for (vi, (line_layout, _buffer_line)) in last_layout - .lines - .iter() - .zip(last_layout.visible_buffer_lines.iter()) - .enumerate() - { - let line_start_offset = last_layout.visible_line_byte_offsets[vi]; - - // Calculate line origin for this display row - let line_origin = point(px(0.), y_offset); - let pos = inner_position - line_origin; - - // Return offset by use closest_index_for_x if is single line mode. - if self.mode.is_single_line() { - let local_index = line_layout.closest_index_for_x(pos.x, last_layout); - let index = line_start_offset + local_index; - return if self.masked { - self.text.char_index_to_offset(index / MASK_CHAR.len_utf8()) - } else { - index.min(self.text.len()) - }; - } - - // Check if mouse is in this line's bounds - if let Some(local_index) = line_layout.closest_index_for_position(pos, last_layout) { - let index = line_start_offset + local_index; - return if self.masked { - self.text.char_index_to_offset(index / MASK_CHAR.len_utf8()) - } else { - index.min(self.text.len()) - }; - } else if pos.y < px(0.) { - // Mouse is above this line, return start of this line - return if self.masked { - self.text - .char_index_to_offset(line_start_offset / MASK_CHAR.len_utf8()) - } else { - line_start_offset - }; - } - - y_offset += line_layout.size(line_height).height; - } - - // Mouse is below all visible lines, return end of text - self.text.len() - } - - /// Returns a y offsetted point for the line origin. - /// Select the text from the current cursor position to the given offset. - /// - /// The offset is the UTF-8 offset. - /// - /// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset. - 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 { - self.selected_range.end = offset - }; - - 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).into(); - } - - // Ensure keep word selected range - if let Some(word_range) = self.selected_word_range.as_ref() { - if self.selected_range.start > word_range.start { - self.selected_range.start = word_range.start; - } - if self.selected_range.end < word_range.end { - self.selected_range.end = word_range.end; - } - } - - if self.selected_range.is_empty() { - self.update_preferred_column(); - } - - cx.notify() - } - - /// 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() - } - - #[inline] - pub(super) fn offset_from_utf16(&self, offset: usize) -> usize { - self.text.offset_utf16_to_offset(offset) - } - - #[inline] - pub(super) fn offset_to_utf16(&self, offset: usize) -> usize { - self.text.offset_to_offset_utf16(offset) - } - - #[inline] - pub(super) fn range_to_utf16(&self, range: &Range) -> Range { - self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end) - } - - #[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) - } - - pub(super) fn previous_boundary(&self, offset: usize) -> usize { - let mut offset = self.text.clip_offset(offset.saturating_sub(1), Bias::Left); - if let Some(ch) = self.text.char_at(offset) - && ch == '\r' - { - offset -= 1; - } - - offset - } - - pub(super) fn next_boundary(&self, offset: usize) -> usize { - let mut offset = self.text.clip_offset(offset + 1, Bias::Right); - if let Some(ch) = self.text.char_at(offset) - && 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.disabled - && self.blink_cursor.read(cx).visible() - && window.is_window_active() - } - - fn on_focus(&mut self, _: &mut Window, cx: &mut Context) { - self.blink_cursor.update(cx, |cursor, cx| { - cursor.start(cx); - }); - cx.emit(InputEvent::Focus); - } - - fn on_blur(&mut self, window: &mut Window, cx: &mut Context) { - self.blink_cursor.update(cx, |cursor, cx| { - cursor.stop(cx); - }); - Root::update(window, cx, |root, _, _| { - root.focused_input = None; - }); - cx.emit(InputEvent::Blur); - cx.notify(); - } - - pub(super) fn pause_blink_cursor(&mut self, cx: &mut Context) { - self.blink_cursor.update(cx, |cursor, cx| { - cursor.pause(cx); - }); - } - - pub(super) fn on_key_down(&mut self, _: &KeyDownEvent, _: &mut Window, cx: &mut Context) { - self.pause_blink_cursor(cx); - } - - pub(super) fn on_drag_move( - &mut self, - event: &MouseMoveEvent, - window: &mut Window, - cx: &mut Context, - ) { - if self.text.len() == 0 { - return; - } - - if self.last_layout.is_none() { - return; - } - - if !self.focus_handle.is_focused(window) { - return; - } - - if !self.selecting { - return; - } - - let offset = self.index_for_mouse_position(event.position); - self.select_to(offset, cx); - } - - 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 - && !validate(new_text, cx) - { - return false; - } - - if !self.mask_pattern.is_valid(new_text) { - return false; - } - - let Some(pattern) = &self.pattern else { - return true; - }; - - pattern.is_match(new_text) - } - - /// Set the mask pattern for formatting the input text. - /// - /// The pattern can contain: - /// - 9: Any digit or dot - /// - A: Any letter - /// - *: Any character - /// - Other characters will be treated as literal mask characters - /// - /// Example: "(999)999-999" for phone numbers - pub fn mask_pattern(mut self, pattern: impl Into) -> Self { - self.mask_pattern = pattern.into(); - if let Some(placeholder) = self.mask_pattern.placeholder() { - self.placeholder = placeholder.into(); - } - self - } - - pub fn set_mask_pattern( - &mut self, - pattern: impl Into, - _: &mut Window, - cx: &mut Context, - ) { - self.mask_pattern = pattern.into(); - if let Some(placeholder) = self.mask_pattern.placeholder() { - self.placeholder = placeholder.into(); - } - cx.notify(); - } - - pub(super) fn set_input_bounds(&mut self, new_bounds: Bounds, cx: &mut Context) { - let wrap_width_changed = self.input_bounds.size.width != new_bounds.size.width; - self.input_bounds = new_bounds; - - // Update display_map wrap_width if changed. - if let Some(last_layout) = self.last_layout.as_ref() - && 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.display_map.on_layout_changed(wrap_width, cx); - self.mode.update_auto_grow(&self.display_map); - cx.notify(); - } - } - - pub(super) fn selected_text(&self) -> RopeSlice<'_> { - let range_utf16 = self.range_to_utf16(&self.selected_range.into()); - let range = self.range_from_utf16(&range_utf16); - self.text.slice(range) - } - - /// Return the rendered bounds for a UTF-8 byte range in the current input contents. - /// - /// Returns `None` when the requested range is not currently laid out or visible. - pub fn range_to_bounds(&self, range: &Range) -> Option> { - let last_layout = self.last_layout.as_ref()?; - let last_bounds = self.last_bounds?; - - let (_, _, start_pos) = self.line_and_position_for_offset(range.start); - let (_, _, end_pos) = self.line_and_position_for_offset(range.end); - - let start_pos = start_pos?; - let end_pos = end_pos?; - - Some(Bounds::from_corners( - last_bounds.origin + start_pos, - last_bounds.origin + end_pos + point(px(0.), last_layout.line_height), - )) - } - - /// Replace text in range in silent. - /// - /// This will not trigger any UI interaction, such as auto-completion. - pub(crate) fn replace_text_in_range_silent( - &mut self, - range_utf16: Option>, - new_text: &str, - window: &mut Window, - cx: &mut Context, - ) { - self.silent_replace_text = true; - self.replace_text_in_range(range_utf16, new_text, window, cx); - self.silent_replace_text = false; - } -} - -impl EntityInputHandler for InputState { - fn text_for_range( - &mut self, - range_utf16: Range, - adjusted_range: &mut Option>, - _window: &mut Window, - _cx: &mut Context, - ) -> Option { - let range = self.range_from_utf16(&range_utf16); - adjusted_range.replace(self.range_to_utf16(&range)); - Some(self.text.slice(range).to_string()) - } - - fn selected_text_range( - &mut self, - _ignore_disabled_input: bool, - _window: &mut Window, - _cx: &mut Context, - ) -> Option { - Some(UTF16Selection { - range: self.range_to_utf16(&self.selected_range.into()), - reversed: false, - }) - } - - fn marked_text_range( - &self, - _window: &mut Window, - _cx: &mut Context, - ) -> Option> { - self.ime_marked_range - .map(|range| self.range_to_utf16(&range.into())) - } - - fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) { - self.ime_marked_range = None; - } - - /// Replace text in range. - /// - /// - If the new text is invalid, it will not be replaced. - /// - If `range_utf16` is not provided, the current selected range will be used. - fn replace_text_in_range( - &mut self, - range_utf16: Option>, - new_text: &str, - _window: &mut Window, - cx: &mut Context, - ) { - if self.disabled { - return; - } - - if self.blink_cursor.read(cx).visible() { - self.pause_blink_cursor(cx); - } - - let range = range_utf16 - .as_ref() - .map(|range_utf16| self.range_from_utf16(range_utf16)) - .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 old_text = self.text.clone(); - self.text.replace(range.clone(), new_text); - - 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()); - } - } - - self.push_history(&old_text, &range, new_text); - self.history.end_grouping(); - - // Adjust folds before updating wrap map: remove overlapping folds and shift others - self.display_map - .adjust_folds_for_edit(&old_text, &range, new_text); - self.display_map - .on_text_changed(&self.text, &range, &Rope::from(new_text), cx); - - self.selected_range = (new_offset..new_offset).into(); - self.ime_marked_range.take(); - self.update_preferred_column(); - self.mode.update_auto_grow(&self.display_map); - if self.emit_events { - cx.emit(InputEvent::Change); - } - cx.notify(); - } - - /// Mark text is the IME temporary insert on typing. - fn replace_and_mark_text_in_range( - &mut self, - range_utf16: Option>, - new_text: &str, - new_selected_range_utf16: Option>, - _window: &mut Window, - cx: &mut Context, - ) { - if self.disabled { - return; - } - - let range = range_utf16 - .as_ref() - .map(|range_utf16| self.range_from_utf16(range_utf16)) - .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 old_text = self.text.clone(); - self.text.replace(range.clone(), new_text); - - 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; - } - } - - // Adjust folds before updating wrap map: remove overlapping folds and shift others - self.display_map - .adjust_folds_for_edit(&old_text, &range, new_text); - self.display_map - .on_text_changed(&self.text, &range, &Rope::from(new_text), cx); - - if new_text.is_empty() { - // Cancel selection, when cancel IME input. - self.selected_range = (range.start..range.start).into(); - self.ime_marked_range = None; - } else { - 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()) - .into(); - } - - self.mode.update_auto_grow(&self.display_map); - self.history.start_grouping(); - self.push_history(&old_text, &range, new_text); - cx.notify(); - } - - /// Used to position IME candidates. - fn bounds_for_range( - &mut self, - range_utf16: Range, - bounds: Bounds, - _window: &mut Window, - _cx: &mut Context, - ) -> 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 line_number_origin = point(line_number_width, px(0.)); - let mut y_offset = last_layout.visible_top; - - for (vi, line) in last_layout.lines.iter().enumerate() { - if start_origin.is_some() && end_origin.is_some() { - break; - } - - let index_offset = last_layout.visible_line_byte_offsets[vi]; - - if start_origin.is_none() - && let Some(p) = line.position_for_index( - range.start.saturating_sub(index_offset), - last_layout, - false, - ) - { - start_origin = Some(p + point(px(0.), y_offset)); - } - - if end_origin.is_none() - && let Some(p) = line.position_for_index( - range.end.saturating_sub(index_offset), - last_layout, - false, - ) - { - end_origin = Some(p + point(px(0.), y_offset)); - } - - y_offset += line.size(line_height).height; - } - - let start_origin = start_origin.unwrap_or_default(); - let mut end_origin = end_origin.unwrap_or_default(); - // Ensure at same line. - end_origin.y = start_origin.y; - - Some(Bounds::from_corners( - bounds.origin + line_number_origin + start_origin, - // + line_height for show IME panel under the cursor line. - bounds.origin + line_number_origin + point(end_origin.x, end_origin.y + line_height), - )) - } - - fn character_index_for_point( - &mut self, - point: gpui::Point, - _window: &mut Window, - _cx: &mut Context, - ) -> Option { - let last_layout = self.last_layout.as_ref()?; - let line_point = self.last_bounds?.localize(&point)?; - - for (vi, line) in last_layout.lines.iter().enumerate() { - let offset = last_layout.visible_line_byte_offsets[vi]; - if let Some(utf8_index) = line.index_for_position(line_point, last_layout) { - return Some(self.offset_to_utf16(offset + utf8_index)); - } - } - - None - } -} - -impl Focusable for InputState { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for InputState { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - div() - .id("input-state") - .flex_1() - .when(self.mode.is_multi_line(), |this| this.h_full()) - .flex_grow_1() - .overflow_x_hidden() - .child(TextElement::new(cx.entity().clone()).placeholder(self.placeholder.clone())) - } -} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 114becd9..70515f82 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -15,7 +15,6 @@ pub mod button; pub mod divider; pub mod dock; pub mod group_box; -pub mod history; pub mod indicator; pub mod input; pub mod menu; @@ -43,7 +42,6 @@ mod window_ext; pub fn init(cx: &mut gpui::App) { gpui_base::init(cx); theme::sync_base(cx); - input::init(cx); modal::init(cx); popover::init(cx); menu::init(cx); diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index 9ba463aa..34f52fcb 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -13,7 +13,6 @@ use theme::{ CLIENT_SIDE_DECORATION_SHADOW, }; -use crate::input::InputState; use crate::modal::Modal; use crate::notification::{Notification, NotificationList}; @@ -50,9 +49,6 @@ pub struct Root { /// Notification layer pub(crate) notification: Entity, - /// Current focused input - pub(crate) focused_input: Option>, - /// App view view: AnyView, } @@ -60,7 +56,6 @@ pub struct Root { impl Root { pub fn new(view: AnyView, window: &mut Window, cx: &mut Context) -> Self { Self { - focused_input: None, active_modals: Vec::new(), notification: cx.new(|cx| NotificationList::new(window, cx)), view, @@ -171,8 +166,6 @@ impl Root { /// Close the topmost modal. pub fn close_modal(&mut self, window: &mut Window, cx: &mut Context) { - self.focused_input = None; - if let Some(handle) = self .active_modals .pop() @@ -187,7 +180,6 @@ impl Root { /// Close all modals. pub fn close_all_modals(&mut self, window: &mut Window, cx: &mut Context) { - self.focused_input = None; self.active_modals.clear(); let previous_focused_handle = self diff --git a/crates/ui/src/window_ext.rs b/crates/ui/src/window_ext.rs index d4ed229b..e042c448 100644 --- a/crates/ui/src/window_ext.rs +++ b/crates/ui/src/window_ext.rs @@ -3,7 +3,6 @@ use std::rc::Rc; use gpui::{App, ElementId, Entity, Window}; use crate::Root; -use crate::input::InputState; use crate::modal::Modal; use crate::notification::Notification; @@ -43,12 +42,6 @@ pub trait WindowExtension: Sized { /// Clear all notifications fn clear_notifications(&mut self, cx: &mut App); - - /// Return current focused Input entity. - fn focused_input(&mut self, cx: &mut App) -> Option>; - - /// Returns true if there is a focused Input entity. - fn has_focused_input(&mut self, cx: &mut App) -> bool; } impl WindowExtension for Window { @@ -122,12 +115,4 @@ impl WindowExtension for Window { let entity = Root::read(self, cx).notification.clone(); Rc::new(entity.read(cx).notifications()) } - - fn has_focused_input(&mut self, cx: &mut App) -> bool { - Root::read(self, cx).focused_input.is_some() - } - - fn focused_input(&mut self, cx: &mut App) -> Option> { - Root::read(self, cx).focused_input.clone() - } } diff --git a/crates/workspace/src/panels/profile.rs b/crates/workspace/src/panels/profile.rs index 5bee4cb3..a6c7fd19 100644 --- a/crates/workspace/src/panels/profile.rs +++ b/crates/workspace/src/panels/profile.rs @@ -15,7 +15,7 @@ use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; -use ui::input::{Input, InputState}; +use ui::input::{Input, InputState, Textarea, TextareaState}; use ui::notification::Notification; use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; @@ -38,7 +38,7 @@ pub struct ProfilePanel { avatar_input: Entity, /// User's bio multi line input - bio_input: Entity, + bio_input: Entity, /// User's website url text input website_input: Entity, @@ -64,8 +64,7 @@ impl ProfilePanel { // Use multi-line input for bio let bio_input = cx.new(|cx| { - InputState::new(window, cx) - .multi_line(true) + TextareaState::new(window, cx) .auto_grow(3, 8) .placeholder("A short introduce about you.") }); @@ -367,7 +366,7 @@ impl Render for ProfilePanel { .text_color(cx.theme().text_muted) .child(SharedString::from("A short introduction about you:")), ) - .child(Input::new(&self.bio_input).small()), + .child(Textarea::new(&self.bio_input).small()), ) .child( v_flex() diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index b8181182..b72d590a 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -257,10 +257,10 @@ impl Sidebar { } /// Set the finding status - fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context) { + fn set_finding(&mut self, status: bool, window: &mut Window, cx: &mut Context) { // Disable the input to prevent duplicate requests self.find_input.update(cx, |this, cx| { - this.set_loading(status, cx); + this.set_loading(status, window, cx); }); // Set the search status self.finding = status; @@ -530,15 +530,18 @@ impl Render for Sidebar { .small() .text_xs() .disabled(loading) - .when(!self.find_input.read(cx).loading, |this| { - this.suffix( - Button::new("find-icon") - .icon(IconName::Search) - .tooltip("Press Enter to search") - .transparent() - .small(), - ) - }), + .when( + !self.find_input.read(cx).presentation().is_loading(), + |this| { + this.suffix( + Button::new("find-icon") + .icon(IconName::Search) + .tooltip("Press Enter to search") + .transparent() + .small(), + ) + }, + ), ), ) .child( diff --git a/docs/gpui-base-migration.md b/docs/gpui-base-migration.md index 781572d1..2673260f 100644 --- a/docs/gpui-base-migration.md +++ b/docs/gpui-base-migration.md @@ -18,7 +18,10 @@ repository at `main`, and this workspace's `Cargo.lock` (zed at `4b47ceb`, of dead weight are gone. `history.rs` moved to phase 2 once it turned out its only consumer is `input/state.rs`. No dependency became unused, so the pruning step is a no-op (four dependencies were already unused before this work). -- **Phases 2-5: not started.** +- **Phase 2: landed.** `input/` runs on base's editing engine. 6,715 lines of + engine and history are gone and 306 are written, taking `crates/ui/src/input` + from 6,573 lines to 321. Six call sites changed, all named in phase 2 below. +- **Phases 3-5: not started.** - One pre-existing, unrelated breakage was found; see [A pre-existing wasm blocker](#a-pre-existing-wasm-blocker). @@ -46,7 +49,7 @@ deeper than `ui::::`: | Module | Items | Consumer files | | --- | --- | --- | | crate root (`Icon`, `IconName`, `h_flex`, `v_flex`, `divider`, `Root`, `TitleBar`, `Sizable`, `Selectable`, `Disableable`, `StyledExt`, `WindowExtension`, `InteractiveElementExt`) | 13 | 17 | -| `input` (`InputState`, `Input`, `InputEvent`) | 3 | 10 | +| `input` (`InputState`, `Input`, `InputEvent`; plus `TextareaState` and `Textarea` after phase 2) | 3, then 5 | 10 | | `button` (`Button`, `ButtonVariants`) | 2 | 14 | | `dock` (`Panel`, `PanelView`, `DockArea`, `DockItem`, `DockPlacement`, `PanelEvent`, `ClosePanel`) | 7 | 10 | | `notification`, `avatar`, `menu`, `scroll`, `group_box`, `indicator`, `switch`, `modal`, `tooltip` | 12 | 16 | @@ -108,7 +111,7 @@ dragging, both of which are projected. | `ui` module | LOC | Plan | `gpui-base` counterpart | | --- | --- | --- | --- | -| `input/` (state, element, display_map, rope_ext, mask_pattern, movement, selection, indent, mode, change, cursor, blink_cursor, clear_button) | 6,929 | Replace; keep `ui::input::{Input, InputEvent, InputState}` as the import path | `Input`/`InputState`, `Textarea`/`TextareaState`, `Editor` | +| `input/` (input, clear_button) | 6,573 | Replace; keep `ui::input::{Input, InputEvent, InputState}` as the import path. 321 lines remain, and the engine paints itself through `InputEditorStyle` | `InputState`/`TextareaState` (`InputBaseState` in two modes) plus the `InputBase` frame | | `list/` | 1,477 | Delete | GPUI's own `list` (already in use) | | `checkbox.rs` | 312 | Delete | `Checkbox` | | `scroll/` (scrollbar, scrollable, scrollable_mask) | 1,332 | Replace; keep the `ScrollableElement` and `Scrollbar` names | `Scrollbar`, `ScrollableMask` | @@ -120,13 +123,13 @@ dragging, both of which are projected. | `button.rs` | 626 | Skin: base behavior plus coop's existing variant tables | `Button`, `StateStyle` | | `switch.rs` | 287 | Skin | `Switch`, `SwitchTrack`, `SwitchThumb` | | `avatar.rs` | 141 | Skin | `Avatar`, `AvatarImage`, `AvatarFallback` | -| `history.rs` | 184 | Defer to phase 2 | `UndoHistory`, not `History`: base's `History` is navigation (back/forward), while `UndoHistory` is the grouped undo/redo with `max_undos`, `group_interval`, `start_grouping`/`end_grouping`, and `set_ignoring` in place of the fork's `pub(crate) ignore` field. Its only consumer is `input/state.rs`, which phase 2 replaces | +| `history.rs` | 184 | Delete. Base's input keeps its own `UndoManager`, and `UndoHistory` is a separate public utility the input never touches, so nothing has to be re-based. Its only consumer was `input/state.rs` | — | | `index_path.rs`, `element_ext.rs`, `event.rs`, `focusable.rs` | 156 | Delete | `IndexPath`, `ElementExt`, `InteractiveElementExt`. `FocusableCycle` has no counterpart — base's `FocusableExt` is a different concept (whether a component draws a focus ring) — so it is dropped rather than re-based | | `styled.rs`, `actions.rs`, `animation.rs` | 305 | Keep `ui::StyledExt`, `Size`, and `Sizable` as the app's import. `Selectable`, `Disableable`, and `Collapsible` now come from `gpui_base::component_traits`; the local three-line `h_flex`/`v_flex` wrappers stay rather than delegating to base's identical ones | `styled`, `StateStyle` | | `icon.rs`, `kbd.rs`, `divider.rs`, `skeleton.rs`, `group_box.rs`, `indicator.rs` | 1,023 | Keep; no base equivalent, these are the design system | — | | `menu/` | 2,208 | Keep; base has no menu. Optional later: re-base anchoring and dismissal on `Popup`/`Positioner` | `Popup` (optional) | | `dock/` + `tab/` | 3,356 | Keep for now; see phase 5 | base dock (different contract) | -| `root.rs`, `window_ext.rs`, `title_bar.rs` | 965 | Keep; app shell. `Root` continues to host the dialog and toast layers and `focused_input` | — | +| `root.rs`, `window_ext.rs`, `title_bar.rs` | 965 | Keep; app shell. `Root` continues to host the dialog and toast layers. Its `focused_input` field and the two `WindowExtension` methods that read it are gone — the only thing that ever set them was the deleted input paint hook, and no crate consumed them | — | Roughly 10k lines are removed, 3k are re-expressed as thin skins, and 8k are kept. @@ -230,30 +233,82 @@ touched are the two manifests, `ui/src/lib.rs`, `ui/src/styled.rs`, and acceptance — launching the app and walking the settings dialog and chat panel — has to be done by hand and has not been run. -### Phase 2 — `input/` (the largest single win, ~6.9k lines) +### Phase 2 — `input/` (the largest single win) — landed -The mapping is close to 1:1 with what the app actually uses: +`crates/ui/src/input` is three files and 321 lines: a rewritten 299-line `input.rs`, a +7-line `mod.rs` that re-exports base, and the untouched 15-line `clear_button.rs`. Deleted: +`state.rs`, `element.rs`, `display_map/`, `rope_ext.rs`, `mask_pattern.rs`, `movement.rs`, +`selection.rs`, `indent.rs`, `mode.rs`, `change.rs`, `cursor.rs`, `blink_cursor.rs`, and +`history.rs` — 6,715 lines. -| Coop today | `gpui-base` | +The names the application imports are unchanged, but two of them are base's now: + +| Coop before | `ui::input` now | | --- | --- | -| `InputState::new(window, cx).placeholder(..)` | same | -| `.auto_grow(1, 20)` (chat composer) | `TextareaState::auto_grow(2, 8)` with `Textarea` | -| `.masked(true)` (nsec, password, key) | `InputState::masked(true)`, `unmask_value()` | -| `.set_value(value, window, cx)` | `set_value(value, window, cx)` | -| `InputEvent::{Change, PressEnter, Focus, Blur}` | identical variants | -| `Input::new(&state).appearance(false)` | coop's `Input` keeps these chrome options | +| `InputState`, one struct that became multi-line through `auto_grow`/`multi_line` | `InputState` = `InputBaseState` and `TextareaState` = `InputBaseState`; multi-line is a property of the state's kind | +| `Input`, one element that rendered whatever kind of state it was given | `Input` for `InputState` and `Textarea` for `TextareaState` — one generic element, two names | +| `InputEvent::{Change, PressEnter, Focus, Blur}` | identical | +| `history::History` and `HistoryItem` | gone; `Change` keeps no trait impl | -Known gaps to reconcile here, verified against the 0.6.1 source before starting: -`clean_on_escape()`, `set_loading()` (called from `crates/workspace/src/sidebar/mod.rs`), -and the `InputEditorStyle` hook that has to be filled from coop tokens. Everything else -in `input/` — `display_map`, `rope_ext`, `mask_pattern`, `movement`, `selection`, -`indent`, `mode`, `element` — is deleted. Afterwards, `ropey`, `sum_tree`, -`lsp-types`, `tree-sitter`, `regex`, `unicode-segmentation`, and `uuid` can probably -leave `crates/ui`'s manifest. +The styled element is a frame around base's engine rather than the engine itself. Base's +`InputBaseState::render` registers the key context, the focus handle, every editing +action, the text element and the editor scrollbar, so the coop element no longer carries +any of it. What is left is chrome — background, radius, font size, prefix and suffix +slots, clear button, mask toggle, loading indicator — plus three projections onto the +state: -Surfaces to re-verify: the chat composer (auto-grow, Enter to send, IME), the subject -line, the settings dialog, profile, relay and messaging lists, the import/restore/backup -dialogs, and sidebar search. +- `set_editor_style(InputEditorStyle)`, filling `foreground`, `muted_foreground`, + `selection` and `caret` from `text`, `text_muted`, `selection` and `cursor`. Base + resolves any color left transparent from its own palette, and that palette is only a + projection of coop's, so every color coop paints with is named rather than left to + resolve. The remaining fields stay at base's defaults: coop configures no highlighter, + no diagnostics and no gutter. +- `set_editor_paddings(Edges)`, for multi-line only, resolved from the same `Size` + table the single-line frame applies, through the window's rem size. Base puts that + padding on the text element itself so the text, the gutter and the scrollbar share one + inset; putting it on the frame *and* passing it here would double it. Passing the + frame's own value is also what keeps the scrollbar where the fork drew it. +- `set_disabled` and `set_text_align`, replacing the fork's direct writes to `state.size`, + `state.disabled` and `state.text_align`. + +`history.rs` folded in as predicted, with one correction: it did not need re-basing at +all. Base's engine owns an `UndoManager`, and `gpui_base::UndoHistory` — the grouped +undo/redo, not the back/forward `History` — is a separate utility the engine never +reaches for. Removing `pub mod history` is safe because nothing outside `crates/ui` +referenced it. + +The gaps named before the phase started all resolved in base's favor: `clean_on_escape()` +and `set_loading()` both exist in 0.6.1, and `InputEditorStyle` is the third piece. + +**Six call sites changed, and none of them is churn:** + +| Call site | Change | Why | +| --- | --- | --- | +| `chat_ui` composer | `InputState` → `TextareaState`, `Input::new` → `Textarea::new` | multi-line is the state's kind, not a layout flag | +| `workspace`, profile bio | the same, and `.multi_line(true)` is dropped | the same; `auto_grow(3, 8)` is unchanged | +| `workspace`, sidebar | `set_loading(status, cx)` → `set_loading(status, window, cx)` | base's signature takes the window | +| `workspace`, sidebar | the `.loading` field read → `.presentation().is_loading()` | `loading` is private; `InputPresentation` is the facade for reading it | +| `ui::window_ext` | `focused_input` and `has_focused_input` are removed | their only implementation was the deleted paint hook, and no crate consumed them | +| `ui::init` | `input::init(cx)` is removed | `gpui_base::init` binds the same keys, and its set is a strict superset | + +Three visible differences survive, all of them base's, none of them a color, radius or +spacing value: + +- **The mask character is `•`, not `*`.** Base's `MASK_CHAR` is a private constant, so the + fork's `*` cannot be restored. It shows only in masked inputs. +- **The caret is `0.85 × line_height` at every size.** The fork scaled it by `Size` (0.75 + at small, 1.0 at large) from a `size` field base does not have. +- **Inputs are tab stops.** Base builds the state's focus handle with `tab_stop(true)`; + the fork's frame was not a tab stop, so Tab skipped text fields and now lands on them. + +Two things came along with `InputBase` that the fork's plain `div` did not do: the frame +carries the `TextInput` accessibility role, and a left click anywhere in the frame — +including the padding outside the text element — focuses the input. The second has to be +restated on the frame because base handles its own mouse events on the inner element only. + +Surfaces to re-verify by hand: the chat composer (auto-grow, Enter to send, IME), the +profile bio, the subject line, the settings dialog, the relay and messaging lists, the +import/restore/backup dialogs, and the sidebar search field. ### Phase 3 — overlays and feedback @@ -298,9 +353,10 @@ There is no UI test suite to lean on, so each phase gets the same treatment: densest single smoke surface (Button, GroupBox, Switch, Input, DropdownMenu, PopupMenuItem), followed by the chat panel and the sidebar. - For phase 4, record before/after screenshots per module. -- Keep the call-site diff at zero for phases 1, 3, and 4; if a call site has to change - because base has no equivalent (`set_loading` is the known candidate), list it in the - pull request. +- Keep the call-site diff at zero where the phase claims it — phases 1 and 3–4 do; if a + call site has to change because base has no equivalent, list it in the pull request. + Phase 2 needed six, tabulated above, and the list is the record of what "no equivalent" + turned out to mean in practice. ### A pre-existing wasm blocker @@ -345,7 +401,7 @@ compile for `wasm32-unknown-unknown`". | --- | --- | --- | --- | | 1 | Phase 0: `gpui` moves to the `gpui-pre` package, `gpui_tokio` vendored | root `Cargo.toml`, `Cargo.lock`, `web/Cargo.toml`, new `crates/gpui_tokio`; `crates/state` needed no edit | landed | | 2 | Phase 1: base wiring, `sync_base`, deletions | `crates/theme` | landed | -| 3 | Phase 2: input, plus `history.rs` → `UndoHistory` and the `ropey`/`sum_tree`/… pruning | none, or the named gaps | not started | +| 3 | Phase 2: input, plus `history.rs` and the `ropey`/`sum_tree`/`lsp-types`/`regex`/`unicode-segmentation`/`tree-sitter` pruning | `crates/workspace`, `crates/chat_ui` (six call sites); no manifest outside `crates/ui` | landed | | 4 | Phase 3: popover, modal, notification, tooltip | none | not started | | 5–10 | Phase 4: one leaf module each | none | not started | | later | Phase 5: dock, as its own plan | `crates/workspace`, `crates/chat_ui` | not started |