From 2a918bf4de05cea013527e8592845b9b2281e324 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Wed, 10 Sep 2025 18:52:35 +0800 Subject: [PATCH] input: Improve TextWrapper data structure. (#1237) --- crates/ui/src/input/cursor.rs | 9 +++ crates/ui/src/input/element.rs | 53 ++++++++-------- crates/ui/src/input/marker.rs | 43 +++---------- crates/ui/src/input/mode.rs | 2 +- crates/ui/src/input/rope_ext.rs | 9 +-- crates/ui/src/input/state.rs | 14 +++-- crates/ui/src/input/text_wrapper.rs | 97 ++++++++++++++++------------- 7 files changed, 115 insertions(+), 112 deletions(-) diff --git a/crates/ui/src/input/cursor.rs b/crates/ui/src/input/cursor.rs index 9664c2d7..89197583 100644 --- a/crates/ui/src/input/cursor.rs +++ b/crates/ui/src/input/cursor.rs @@ -70,6 +70,15 @@ impl From for LineColumn { } } +impl From for rope::Point { + fn from(value: LineColumn) -> Self { + Self { + row: value.line.saturating_sub(1) as u32, + column: value.column.saturating_sub(1) as u32, + } + } +} + impl From for tree_sitter::Point { fn from(value: LineColumn) -> Self { Self { diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs index 668e4197..997da743 100644 --- a/crates/ui/src/input/element.rs +++ b/crates/ui/src/input/element.rs @@ -392,7 +392,7 @@ impl TextElement { let mut visible_range = 0..total_lines; let mut line_bottom = px(0.); for (ix, line) in state.text_wrapper.lines.iter().enumerate() { - let wrapped_height = (line.wrap_lines + 1) * line_height; + let wrapped_height = line.height(line_height); line_bottom += wrapped_height; if line_bottom < -scroll_top { @@ -717,35 +717,38 @@ impl Element for TextElement { .expect("failed to shape text"); // measure.end(); - let longtest_line: SharedString = state - .text - .line(state.text.summary().longest_row as usize) - .to_string() - .into(); - let max_line_width = window - .text_system() - .shape_line( - longtest_line.clone(), - font_size, - &[TextRun { - len: longtest_line.len(), - font: style.font(), - color: gpui::black(), - background_color: None, - underline: None, - strikethrough: None, - }], - wrap_width, - ) - .width; + let mut longest_line_width = px(0.); + if state.mode.is_multi_line() && lines.len() > 1 { + let longtest_line: SharedString = state + .text + .line(state.text.summary().longest_row as usize) + .to_string() + .into(); + longest_line_width = window + .text_system() + .shape_line( + longtest_line.clone(), + font_size, + &[TextRun { + len: longtest_line.len(), + font: style.font(), + color: gpui::black(), + background_color: None, + underline: None, + strikethrough: None, + }], + wrap_width, + ) + .width; + } let total_wrapped_lines = state.text_wrapper.len(); let scroll_size = size( - if max_line_width + line_number_width + RIGHT_MARGIN > bounds.size.width { - max_line_width + line_number_width + RIGHT_MARGIN + if longest_line_width + line_number_width + RIGHT_MARGIN > bounds.size.width { + longest_line_width + line_number_width + RIGHT_MARGIN } else { - max_line_width + longest_line_width }, (total_wrapped_lines as f32 * line_height).max(bounds.size.height), ); diff --git a/crates/ui/src/input/marker.rs b/crates/ui/src/input/marker.rs index eb0874aa..b9c98341 100644 --- a/crates/ui/src/input/marker.rs +++ b/crates/ui/src/input/marker.rs @@ -3,7 +3,6 @@ use crate::{ input::{InputState, LineColumn}, }; use gpui::{px, App, HighlightStyle, Hsla, SharedString, UnderlineStyle}; -use itertools::Itertools; use std::ops::Range; /// Marker represents a diagnostic message, such as an error or warning, in the code editor. @@ -36,41 +35,19 @@ impl Marker { /// Prepare the marker to convert line, column to byte offsets. pub(super) fn prepare(&mut self, state: &InputState) { - let Some(start_line) = state - .text_wrapper - .lines - .get(self.start.line.saturating_sub(1)) - else { - return; - }; + let mut start_point: rope::Point = self.start.into(); + let mut end_point: rope::Point = self.end.into(); - let start_line_str = state.text.slice(start_line.range.clone()); + // limit column avoid overflow + let start_line_len = state.text.line_len(start_point.row); + start_point.column = start_point.column.min(start_line_len); + let end_line_len = state.text.line_len(end_point.row); + end_point.column = end_point.column.min(end_line_len); - let Some(end_line) = state - .text_wrapper - .lines - .get(self.end.line.saturating_sub(1)) - else { - return; - }; - let end_line_str = state.text.slice(end_line.range.clone()); + let start = state.text.point_to_offset(start_point); + let end = state.text.point_to_offset(end_point); - let start_byte = start_line.range.start - + start_line_str - .chars() - .take(self.start.column.saturating_sub(1)) - .counts_by(|c| c.len_utf8()) - .values() - .sum::(); - let end_byte = end_line.range.start - + end_line_str - .chars() - .take(self.end.column.saturating_sub(1)) - .counts_by(|c| c.len_utf8()) - .values() - .sum::(); - - self.range = Some(start_byte..end_byte); + self.range = Some(start..end); } } diff --git a/crates/ui/src/input/mode.rs b/crates/ui/src/input/mode.rs index ba558f11..0699609c 100644 --- a/crates/ui/src/input/mode.rs +++ b/crates/ui/src/input/mode.rs @@ -105,7 +105,7 @@ impl InputMode { } pub(super) fn update_auto_grow(&mut self, text_wrapper: &TextWrapper) { - let wrapped_lines = text_wrapper.wrapped_lines.len(); + let wrapped_lines = text_wrapper.len(); self.set_rows(wrapped_lines); } diff --git a/crates/ui/src/input/rope_ext.rs b/crates/ui/src/input/rope_ext.rs index 7ae9a819..d7669861 100644 --- a/crates/ui/src/input/rope_ext.rs +++ b/crates/ui/src/input/rope_ext.rs @@ -88,9 +88,8 @@ impl std::iter::FusedIterator for RopeLines {} impl RopeExt for Rope { fn line(&self, row: usize) -> Rope { - let row = row as u32; - let start = self.point_to_offset(Point::new(row, 0)); - let end = start + self.line_len(row) as usize; + let start = self.line_start_offset(row); + let end = start + self.line_len(row as u32) as usize; self.slice(start..end) } @@ -145,7 +144,9 @@ mod tests { assert_eq!(rope.line(1).to_string(), "World\r"); assert_eq!(rope.line(2).to_string(), "This is a test 中文"); assert_eq!(rope.line(3).to_string(), "Rope"); - assert_eq!(rope.line(4).to_string(), ""); + + // over bounds + assert_eq!(rope.line(6).to_string(), ""); } #[test] diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index 84aee8af..3b53be17 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -31,7 +31,7 @@ use super::{ }; use crate::input::hover_popover::DiagnosticPopover; use crate::input::marker::Marker; -use crate::input::text_wrapper::LineWrap; +use crate::input::text_wrapper::LineItem; use crate::input::{LineColumn, RopeExt as _, Selection}; use crate::{history::History, scroll::ScrollbarState, Root}; @@ -687,6 +687,11 @@ impl InputState { .unwrap_or(self.input_bounds.size.width); self.text_wrapper.set_wrap_width(Some(wrap_width), cx); + + // Reset scroll to left 0 + let mut offset = self.scroll_handle.offset(); + offset.x = px(0.); + self.scroll_handle.set_offset(offset); } else { self.text_wrapper.set_wrap_width(None, cx); } @@ -725,7 +730,7 @@ impl InputState { pub fn default_value(mut self, value: impl Into) -> Self { let text: SharedString = value.into(); self.text = Rope::from(text.as_str()); - self.text_wrapper.text = self.text.clone(); + self.text_wrapper.set_default_text(&self.text); self } @@ -1722,7 +1727,7 @@ impl InputState { fn line_origin_with_y_offset( &self, y_offset: &mut Pixels, - line: &LineWrap, + line: &LineItem, line_height: Pixels, ) -> Point { // NOTE: About line.wrap_boundaries.len() @@ -1731,8 +1736,7 @@ impl InputState { // If have 2 line, the value is 1 if self.mode.is_multi_line() { let p = point(px(0.), *y_offset); - let height = line_height + line.wrap_lines as f32 * line_height; - *y_offset = *y_offset + height; + *y_offset += line.height(line_height); p } else { point(px(0.), px(0.)) diff --git a/crates/ui/src/input/text_wrapper.rs b/crates/ui/src/input/text_wrapper.rs index 3e7df6a7..19e95245 100644 --- a/crates/ui/src/input/text_wrapper.rs +++ b/crates/ui/src/input/text_wrapper.rs @@ -5,48 +5,51 @@ use rope::Rope; use crate::input::RopeExt as _; -#[allow(unused)] -pub(super) struct LineWrap { - /// The number of soft wrapped lines of this line (Not include first line.) +/// A line with soft wrapped lines info. +#[derive(Clone)] +pub(super) struct LineItem { + /// The original line text. + line: Rope, + /// The soft wrapped lines relative byte range (0..line.len) of this line (Include first line). /// /// FIXME: Here in somecase, the `line_wrapper.wrap_line` has returned different /// like the `window.text_system().shape_text`. So, this value may not equal /// the actual rendered lines. - pub(super) wrap_lines: usize, - /// The range of the line text in the entire text (not includes ending `\n`). - pub(super) range: Range, + wrapped_lines: Vec>, } -impl LineWrap { - /// Return the bytes length of this line. +impl LineItem { + /// Get the bytes length of this line. + #[inline] pub(super) fn len(&self) -> usize { - self.range.end - self.range.start + self.line.len() } - /// Get the total number of lines including wrapped lines. + /// Get number of soft wrapped lines of this line (include the first line). + #[inline] pub(super) fn lines_len(&self) -> usize { - self.wrap_lines + 1 + self.wrapped_lines.len() } - /// Get the height of this line including wrapped lines. + /// Get the height of this line item with given line height. pub(super) fn height(&self, line_height: Pixels) -> Pixels { - self.lines_len() * line_height + self.lines_len() as f32 * line_height } } -/// Used to prepare the text with soft wrap to be get lines to displayed in the TextArea +/// 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 TextArea +/// After use lines to calculate the scroll size of the Editor. pub(super) struct TextWrapper { - pub(super) text: Rope, - /// The wrapped lines (Inlucde the first line), value is start and end index of the line. - pub(super) wrapped_lines: Vec>, - /// The lines by split \n - pub(super) lines: Vec, - pub(super) font: Font, - pub(super) font_size: Pixels, + 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 - pub(super) wrap_width: Option, + wrap_width: Option, + /// The lines by split \n + pub(super) lines: Vec, } #[allow(unused)] @@ -57,14 +60,26 @@ impl TextWrapper { font, font_size, wrap_width, - wrapped_lines: Vec::new(), + soft_lines: 0, lines: Vec::new(), } } + #[inline] + pub(super) fn set_default_text(&mut self, text: &Rope) { + self.text = text.clone(); + } + /// Get the total number of lines including wrapped lines. + #[inline] pub(super) fn len(&self) -> usize { - self.wrapped_lines.len() + self.soft_lines + } + + /// Get the line item by row index. + #[inline] + pub(super) fn line(&self, row: usize) -> Option<&LineItem> { + self.lines.iter().skip(row).next() } pub(super) fn set_wrap_width(&mut self, wrap_width: Option, cx: &mut App) { @@ -94,45 +109,39 @@ impl TextWrapper { return; } - let mut wrapped_lines = vec![]; - let mut lines = vec![]; let wrap_width = self.wrap_width; let mut line_wrapper = cx .text_system() .line_wrapper(self.font.clone(), self.font_size); - let mut prev_line_ix = 0; + self.lines.clear(); for line in text.lines() { - let line = line.to_string(); - let mut line_wraps = vec![]; + let line_str = line.to_string(); + let mut wrapped_lines = vec![]; let mut prev_boundary_ix = 0; // If wrap_width is Pixels::MAX, skip wrapping to disable word wrap if let Some(wrap_width) = wrap_width { // Here only have wrapped line, if there is no wrap meet, the `line_wraps` result will empty. - for boundary in line_wrapper.wrap_line(&[LineFragment::text(&line)], wrap_width) { - line_wraps.push(prev_boundary_ix..boundary.ix); + for boundary in line_wrapper.wrap_line(&[LineFragment::text(&line_str)], wrap_width) + { + wrapped_lines.push(prev_boundary_ix..boundary.ix); prev_boundary_ix = boundary.ix; } } - lines.push(LineWrap { - wrap_lines: line_wraps.len(), - range: prev_line_ix..prev_line_ix + line.len(), - }); - - wrapped_lines.extend(line_wraps); // Reset of the line - if !line[prev_boundary_ix..].is_empty() || prev_boundary_ix == 0 { - wrapped_lines.push(prev_line_ix + prev_boundary_ix..prev_line_ix + line.len()); + if !line_str[prev_boundary_ix..].is_empty() || prev_boundary_ix == 0 { + wrapped_lines.push(prev_boundary_ix..line.len()); } - // +1 for \n - prev_line_ix += line.len() + 1; + self.lines.push(LineItem { + line: line.clone(), + wrapped_lines, + }); } self.text = text.clone(); - self.wrapped_lines = wrapped_lines; - self.lines = lines; + self.soft_lines = self.lines.iter().map(|l| l.lines_len()).sum(); } }