Rework go to line infrastructure (#23654)

Kirill Bulatov created

Closes https://github.com/zed-industries/zed/issues/12024


https://github.com/user-attachments/assets/60ea3dbd-b594-4bf5-a44d-4bff925b815f

* Fixes incorrect line selection for certain corner cases

Before:

<img width="1728" alt="image"
src="https://github.com/user-attachments/assets/35aaee6c-c120-4bf1-9355-448a29d1b9b5"
/>

After:

<img width="1728" alt="image"
src="https://github.com/user-attachments/assets/abd97339-4594-4e8e-8605-50d74581ae86"
/>


* Reworks https://github.com/zed-industries/zed/pull/16420 to display
selection length with less performance overhead.
Improves the performance more, doing a single selections loop instead of
two.

* Fixes incorrect caret position display when text contains UTF-8 chars
with size > 1
Also fixes tooltop values for this case

* Fixes go to line to treat UTF-8 chars with size > 1 properly when
navigating

* Adds a way to fill go to line text editor with its tooltip on `Tab`

Release Notes:

- Fixed incorrect UTF-8 characters handling in `GoToLine` and caret
position

Change summary

crates/go_to_line/src/cursor_position.rs |  78 ++++--
crates/go_to_line/src/go_to_line.rs      | 315 ++++++++++++++++++++++---
crates/rope/src/chunk.rs                 |  13 
crates/rope/src/rope.rs                  |   9 
crates/text/src/tests.rs                 |  19 +
5 files changed, 365 insertions(+), 69 deletions(-)

Detailed changes

crates/go_to_line/src/cursor_position.rs 🔗

@@ -1,9 +1,9 @@
-use editor::{Editor, ToPoint};
+use editor::{Editor, MultiBufferSnapshot};
 use gpui::{AppContext, FocusHandle, FocusableView, Subscription, Task, View, WeakView};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsSources};
-use std::{fmt::Write, time::Duration};
+use std::{fmt::Write, num::NonZeroU32, time::Duration};
 use text::{Point, Selection};
 use ui::{
     div, Button, ButtonCommon, Clickable, FluentBuilder, IntoElement, LabelSize, ParentElement,
@@ -20,7 +20,7 @@ pub(crate) struct SelectionStats {
 }
 
 pub struct CursorPosition {
-    position: Option<(Point, bool)>,
+    position: Option<UserCaretPosition>,
     selected_count: SelectionStats,
     context: Option<FocusHandle>,
     workspace: WeakView<Workspace>,
@@ -28,6 +28,30 @@ pub struct CursorPosition {
     _observe_active_editor: Option<Subscription>,
 }
 
+/// A position in the editor, where user's caret is located at.
+/// Lines are never zero as there is always at least one line in the editor.
+/// Characters may start with zero as the caret may be at the beginning of a line, but all editors start counting characters from 1,
+/// where "1" will mean "before the first character".
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub struct UserCaretPosition {
+    pub line: NonZeroU32,
+    pub character: NonZeroU32,
+}
+
+impl UserCaretPosition {
+    pub fn at_selection_end(selection: &Selection<Point>, snapshot: &MultiBufferSnapshot) -> Self {
+        let selection_end = selection.head();
+        let line_start = Point::new(selection_end.row, 0);
+        let chars_to_last_position = snapshot
+            .text_summary_for_range::<text::TextSummary, _>(line_start..selection_end)
+            .chars as u32;
+        Self {
+            line: NonZeroU32::new(selection_end.row + 1).expect("added 1"),
+            character: NonZeroU32::new(chars_to_last_position + 1).expect("added 1"),
+        }
+    }
+}
+
 impl CursorPosition {
     pub fn new(workspace: &Workspace) -> Self {
         Self {
@@ -73,21 +97,16 @@ impl CursorPosition {
                                 cursor_position.context = None;
                             }
                             editor::EditorMode::Full => {
-                                let mut last_selection = None::<Selection<usize>>;
-                                let buffer = editor.buffer().read(cx).snapshot(cx);
-                                if buffer.excerpts().count() > 0 {
-                                    for selection in editor.selections.all::<usize>(cx) {
-                                        cursor_position.selected_count.characters += buffer
-                                            .text_for_range(selection.start..selection.end)
-                                            .map(|t| t.chars().count())
-                                            .sum::<usize>();
-                                        if last_selection.as_ref().map_or(true, |last_selection| {
-                                            selection.id > last_selection.id
-                                        }) {
-                                            last_selection = Some(selection);
-                                        }
-                                    }
+                                let mut last_selection = None::<Selection<Point>>;
+                                let snapshot = editor.buffer().read(cx).snapshot(cx);
+                                if snapshot.excerpts().count() > 0 {
                                     for selection in editor.selections.all::<Point>(cx) {
+                                        let selection_summary = snapshot
+                                            .text_summary_for_range::<text::TextSummary, _>(
+                                                selection.start..selection.end,
+                                            );
+                                        cursor_position.selected_count.characters +=
+                                            selection_summary.chars;
                                         if selection.end != selection.start {
                                             cursor_position.selected_count.lines +=
                                                 (selection.end.row - selection.start.row) as usize;
@@ -95,13 +114,15 @@ impl CursorPosition {
                                                 cursor_position.selected_count.lines += 1;
                                             }
                                         }
+                                        if last_selection.as_ref().map_or(true, |last_selection| {
+                                            selection.id > last_selection.id
+                                        }) {
+                                            last_selection = Some(selection);
+                                        }
                                     }
                                 }
-                                cursor_position.position = last_selection.and_then(|s| {
-                                    buffer
-                                        .point_to_buffer_point(s.head().to_point(&buffer))
-                                        .map(|(_, point, is_main_buffer)| (point, is_main_buffer))
-                                });
+                                cursor_position.position = last_selection
+                                    .map(|s| UserCaretPosition::at_selection_end(&s, &snapshot));
                                 cursor_position.context = Some(editor.focus_handle(cx));
                             }
                         }
@@ -162,16 +183,19 @@ impl CursorPosition {
     pub(crate) fn selection_stats(&self) -> &SelectionStats {
         &self.selected_count
     }
+
+    #[cfg(test)]
+    pub(crate) fn position(&self) -> Option<UserCaretPosition> {
+        self.position
+    }
 }
 
 impl Render for CursorPosition {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
-        div().when_some(self.position, |el, (position, is_main_buffer)| {
+        div().when_some(self.position, |el, position| {
             let mut text = format!(
-                "{}{}{FILE_ROW_COLUMN_DELIMITER}{}",
-                if is_main_buffer { "" } else { "(deleted) " },
-                position.row + 1,
-                position.column + 1
+                "{}{FILE_ROW_COLUMN_DELIMITER}{}",
+                position.line, position.character,
             );
             self.write_position(&mut text, cx);
 

crates/go_to_line/src/go_to_line.rs 🔗

@@ -1,7 +1,9 @@
 pub mod cursor_position;
 
-use cursor_position::LineIndicatorFormat;
-use editor::{scroll::Autoscroll, Anchor, Editor, MultiBuffer, ToPoint};
+use cursor_position::{LineIndicatorFormat, UserCaretPosition};
+use editor::{
+    actions::Tab, scroll::Autoscroll, Anchor, Editor, MultiBufferSnapshot, ToOffset, ToPoint,
+};
 use gpui::{
     div, prelude::*, AnyWindowHandle, AppContext, DismissEvent, EventEmitter, FocusHandle,
     FocusableView, Model, Render, SharedString, Styled, Subscription, View, ViewContext,
@@ -9,7 +11,7 @@ use gpui::{
 };
 use language::Buffer;
 use settings::Settings;
-use text::Point;
+use text::{Bias, Point};
 use theme::ActiveTheme;
 use ui::prelude::*;
 use util::paths::FILE_ROW_COLUMN_DELIMITER;
@@ -23,7 +25,6 @@ pub fn init(cx: &mut AppContext) {
 pub struct GoToLine {
     line_editor: View<Editor>,
     active_editor: View<Editor>,
-    active_buffer: Model<Buffer>,
     current_text: SharedString,
     prev_scroll_position: Option<gpui::Point<f32>>,
     _subscriptions: Vec<Subscription>,
@@ -67,10 +68,13 @@ impl GoToLine {
         active_buffer: Model<Buffer>,
         cx: &mut ViewContext<Self>,
     ) -> Self {
-        let (cursor, last_line, scroll_position) = active_editor.update(cx, |editor, cx| {
-            let cursor = editor.selections.last::<Point>(cx).head();
-            let snapshot = active_buffer.read(cx).snapshot();
+        let (user_caret, last_line, scroll_position) = active_editor.update(cx, |editor, cx| {
+            let user_caret = UserCaretPosition::at_selection_end(
+                &editor.selections.last::<Point>(cx),
+                &editor.buffer().read(cx).snapshot(cx),
+            );
 
+            let snapshot = active_buffer.read(cx).snapshot();
             let last_line = editor
                 .buffer()
                 .read(cx)
@@ -80,14 +84,32 @@ impl GoToLine {
                 .max()
                 .unwrap_or(0);
 
-            (cursor, last_line, editor.scroll_position(cx))
+            (user_caret, last_line, editor.scroll_position(cx))
         });
 
-        let line = cursor.row + 1;
-        let column = cursor.column + 1;
+        let line = user_caret.line.get();
+        let column = user_caret.character.get();
 
         let line_editor = cx.new_view(|cx| {
             let mut editor = Editor::single_line(cx);
+            let editor_handle = cx.view().downgrade();
+            editor
+                .register_action::<Tab>({
+                    move |_, cx| {
+                        let Some(editor) = editor_handle.upgrade() else {
+                            return;
+                        };
+                        editor.update(cx, |editor, cx| {
+                            if let Some(placeholder_text) = editor.placeholder_text(cx) {
+                                if editor.text(cx).is_empty() {
+                                    let placeholder_text = placeholder_text.to_string();
+                                    editor.set_text(placeholder_text, cx);
+                                }
+                            }
+                        });
+                    }
+                })
+                .detach();
             editor.set_placeholder_text(format!("{line}{FILE_ROW_COLUMN_DELIMITER}{column}"), cx);
             editor
         });
@@ -103,7 +125,6 @@ impl GoToLine {
         Self {
             line_editor,
             active_editor,
-            active_buffer,
             current_text: current_text.into(),
             prev_scroll_position: Some(scroll_position),
             _subscriptions: vec![line_editor_change, cx.on_release(Self::release)],
@@ -141,13 +162,18 @@ impl GoToLine {
     fn highlight_current_line(&mut self, cx: &mut ViewContext<Self>) {
         self.active_editor.update(cx, |editor, cx| {
             editor.clear_row_highlights::<GoToLineRowHighlights>();
-            let multibuffer = editor.buffer().read(cx);
-            let snapshot = multibuffer.snapshot(cx);
-            let Some(start) = self.anchor_from_query(&multibuffer, cx) else {
+            let snapshot = editor.buffer().read(cx).snapshot(cx);
+            let Some(start) = self.anchor_from_query(&snapshot, cx) else {
                 return;
             };
-            let start_point = start.to_point(&snapshot);
-            let end_point = start_point + Point::new(1, 0);
+            let mut start_point = start.to_point(&snapshot);
+            start_point.column = 0;
+            // Force non-empty range to ensure the line is highlighted.
+            let mut end_point = snapshot.clip_point(start_point + Point::new(0, 1), Bias::Left);
+            if start_point == end_point {
+                end_point = snapshot.clip_point(start_point + Point::new(1, 0), Bias::Left);
+            }
+
             let end = snapshot.anchor_after(end_point);
             editor.highlight_rows::<GoToLineRowHighlights>(
                 start..end,
@@ -162,25 +188,49 @@ impl GoToLine {
 
     fn anchor_from_query(
         &self,
-        multibuffer: &MultiBuffer,
+        snapshot: &MultiBufferSnapshot,
         cx: &ViewContext<Editor>,
     ) -> Option<Anchor> {
-        let (Some(row), column) = self.line_column_from_query(cx) else {
-            return None;
-        };
-        let point = Point::new(row.saturating_sub(1), column.unwrap_or(0).saturating_sub(1));
-        multibuffer.buffer_point_to_anchor(&self.active_buffer, point, cx)
+        let (query_row, query_char) = self.line_and_char_from_query(cx)?;
+        let row = query_row.saturating_sub(1);
+        let character = query_char.unwrap_or(0).saturating_sub(1);
+
+        let start_offset = Point::new(row, 0).to_offset(snapshot);
+        const MAX_BYTES_IN_UTF_8: u32 = 4;
+        let max_end_offset = snapshot
+            .clip_point(
+                Point::new(row, character * MAX_BYTES_IN_UTF_8 + 1),
+                Bias::Right,
+            )
+            .to_offset(snapshot);
+
+        let mut chars_to_iterate = character;
+        let mut end_offset = start_offset;
+        'outer: for text_chunk in snapshot.text_for_range(start_offset..max_end_offset) {
+            let mut offset_increment = 0;
+            for c in text_chunk.chars() {
+                if chars_to_iterate == 0 {
+                    end_offset += offset_increment;
+                    break 'outer;
+                } else {
+                    chars_to_iterate -= 1;
+                    offset_increment += c.len_utf8();
+                }
+            }
+            end_offset += offset_increment;
+        }
+        Some(snapshot.anchor_before(snapshot.clip_offset(end_offset, Bias::Left)))
     }
 
-    fn line_column_from_query(&self, cx: &AppContext) -> (Option<u32>, Option<u32>) {
+    fn line_and_char_from_query(&self, cx: &AppContext) -> Option<(u32, Option<u32>)> {
         let input = self.line_editor.read(cx).text(cx);
         let mut components = input
             .splitn(2, FILE_ROW_COLUMN_DELIMITER)
             .map(str::trim)
             .fuse();
-        let row = components.next().and_then(|row| row.parse::<u32>().ok());
+        let row = components.next().and_then(|row| row.parse::<u32>().ok())?;
         let column = components.next().and_then(|col| col.parse::<u32>().ok());
-        (row, column)
+        Some((row, column))
     }
 
     fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
@@ -189,8 +239,8 @@ impl GoToLine {
 
     fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
         self.active_editor.update(cx, |editor, cx| {
-            let multibuffer = editor.buffer().read(cx);
-            let Some(start) = self.anchor_from_query(&multibuffer, cx) else {
+            let snapshot = editor.buffer().read(cx).snapshot(cx);
+            let Some(start) = self.anchor_from_query(&snapshot, cx) else {
                 return;
             };
             editor.change_selections(Some(Autoscroll::center()), cx, |s| {
@@ -207,15 +257,13 @@ impl GoToLine {
 
 impl Render for GoToLine {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
-        let mut help_text = self.current_text.clone();
-        let query = self.line_column_from_query(cx);
-        if let Some(line) = query.0 {
-            if let Some(column) = query.1 {
-                help_text = format!("Go to line {line}, column {column}").into();
-            } else {
-                help_text = format!("Go to line {line}").into();
+        let help_text = match self.line_and_char_from_query(cx) {
+            Some((line, Some(character))) => {
+                format!("Go to line {line}, character {character}").into()
             }
-        }
+            Some((line, None)) => format!("Go to line {line}").into(),
+            None => self.current_text.clone(),
+        };
 
         v_flex()
             .w(rems(24.))
@@ -244,13 +292,13 @@ impl Render for GoToLine {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use cursor_position::{CursorPosition, SelectionStats};
-    use editor::actions::SelectAll;
+    use cursor_position::{CursorPosition, SelectionStats, UserCaretPosition};
+    use editor::actions::{MoveRight, MoveToBeginning, SelectAll};
     use gpui::{TestAppContext, VisualTestContext};
     use indoc::indoc;
     use project::{FakeFs, Project};
     use serde_json::json;
-    use std::{sync::Arc, time::Duration};
+    use std::{num::NonZeroU32, sync::Arc, time::Duration};
     use workspace::{AppState, Workspace};
 
     #[gpui::test]
@@ -439,6 +487,197 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_unicode_line_numbers(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let text = "ēlo你好";
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            "/dir",
+            json!({
+                "a.rs": text
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
+        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
+        workspace.update(cx, |workspace, cx| {
+            let cursor_position = cx.new_view(|_| CursorPosition::new(workspace));
+            workspace.status_bar().update(cx, |status_bar, cx| {
+                status_bar.add_right_item(cursor_position, cx);
+            });
+        });
+
+        let worktree_id = workspace.update(cx, |workspace, cx| {
+            workspace.project().update(cx, |project, cx| {
+                project.worktrees(cx).next().unwrap().read(cx).id()
+            })
+        });
+        let _buffer = project
+            .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
+            .await
+            .unwrap();
+        let editor = workspace
+            .update(cx, |workspace, cx| {
+                workspace.open_path((worktree_id, "a.rs"), None, true, cx)
+            })
+            .await
+            .unwrap()
+            .downcast::<Editor>()
+            .unwrap();
+
+        editor.update(cx, |editor, cx| {
+            editor.move_to_beginning(&MoveToBeginning, cx)
+        });
+        cx.executor().advance_clock(Duration::from_millis(200));
+        assert_eq!(
+            user_caret_position(1, 1),
+            current_position(&workspace, cx),
+            "Beginning of the line should be at first line, before any characters"
+        );
+
+        for (i, c) in text.chars().enumerate() {
+            let i = i as u32 + 1;
+            editor.update(cx, |editor, cx| editor.move_right(&MoveRight, cx));
+            cx.executor().advance_clock(Duration::from_millis(200));
+            assert_eq!(
+                user_caret_position(1, i + 1),
+                current_position(&workspace, cx),
+                "Wrong position for char '{c}' in string '{text}'",
+            );
+        }
+
+        editor.update(cx, |editor, cx| editor.move_right(&MoveRight, cx));
+        cx.executor().advance_clock(Duration::from_millis(200));
+        assert_eq!(
+            user_caret_position(1, text.chars().count() as u32 + 1),
+            current_position(&workspace, cx),
+            "After reaching the end of the text, position should not change when moving right"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_go_into_unicode(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let text = "ēlo你好";
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            "/dir",
+            json!({
+                "a.rs": text
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
+        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
+        workspace.update(cx, |workspace, cx| {
+            let cursor_position = cx.new_view(|_| CursorPosition::new(workspace));
+            workspace.status_bar().update(cx, |status_bar, cx| {
+                status_bar.add_right_item(cursor_position, cx);
+            });
+        });
+
+        let worktree_id = workspace.update(cx, |workspace, cx| {
+            workspace.project().update(cx, |project, cx| {
+                project.worktrees(cx).next().unwrap().read(cx).id()
+            })
+        });
+        let _buffer = project
+            .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
+            .await
+            .unwrap();
+        let editor = workspace
+            .update(cx, |workspace, cx| {
+                workspace.open_path((worktree_id, "a.rs"), None, true, cx)
+            })
+            .await
+            .unwrap()
+            .downcast::<Editor>()
+            .unwrap();
+
+        editor.update(cx, |editor, cx| {
+            editor.move_to_beginning(&MoveToBeginning, cx)
+        });
+        cx.executor().advance_clock(Duration::from_millis(200));
+        assert_eq!(user_caret_position(1, 1), current_position(&workspace, cx));
+
+        for (i, c) in text.chars().enumerate() {
+            let i = i as u32 + 1;
+            let point = user_caret_position(1, i + 1);
+            go_to_point(point, user_caret_position(1, i), &workspace, cx);
+            cx.executor().advance_clock(Duration::from_millis(200));
+            assert_eq!(
+                point,
+                current_position(&workspace, cx),
+                "When going to {point:?}, expecting the cursor to be at char '{c}' in string '{text}'",
+            );
+        }
+
+        go_to_point(
+            user_caret_position(111, 222),
+            user_caret_position(1, text.chars().count() as u32 + 1),
+            &workspace,
+            cx,
+        );
+        cx.executor().advance_clock(Duration::from_millis(200));
+        assert_eq!(
+            user_caret_position(1, text.chars().count() as u32 + 1),
+            current_position(&workspace, cx),
+            "When going into too large point, should go to the end of the text"
+        );
+    }
+
+    fn current_position(
+        workspace: &View<Workspace>,
+        cx: &mut VisualTestContext,
+    ) -> UserCaretPosition {
+        workspace.update(cx, |workspace, cx| {
+            workspace
+                .status_bar()
+                .read(cx)
+                .item_of_type::<CursorPosition>()
+                .expect("missing cursor position item")
+                .read(cx)
+                .position()
+                .expect("No position found")
+        })
+    }
+
+    fn user_caret_position(line: u32, character: u32) -> UserCaretPosition {
+        UserCaretPosition {
+            line: NonZeroU32::new(line).unwrap(),
+            character: NonZeroU32::new(character).unwrap(),
+        }
+    }
+
+    fn go_to_point(
+        new_point: UserCaretPosition,
+        expected_placeholder: UserCaretPosition,
+        workspace: &View<Workspace>,
+        cx: &mut VisualTestContext,
+    ) {
+        let go_to_line_view = open_go_to_line_view(workspace, cx);
+        go_to_line_view.update(cx, |go_to_line_view, cx| {
+            assert_eq!(
+                go_to_line_view
+                    .line_editor
+                    .read(cx)
+                    .placeholder_text(cx)
+                    .expect("No placeholder text"),
+                format!(
+                    "{}:{}",
+                    expected_placeholder.line, expected_placeholder.character
+                )
+            );
+        });
+        cx.simulate_input(&format!("{}:{}", new_point.line, new_point.character));
+        cx.dispatch_action(menu::Confirm);
+    }
+
     fn open_go_to_line_view(
         workspace: &View<Workspace>,
         cx: &mut VisualTestContext,

crates/rope/src/chunk.rs 🔗

@@ -162,9 +162,11 @@ impl<'a> ChunkSlice<'a> {
 
     #[inline(always)]
     pub fn text_summary(&self) -> TextSummary {
-        let (longest_row, longest_row_chars) = self.longest_row();
+        let mut chars = 0;
+        let (longest_row, longest_row_chars) = self.longest_row(&mut chars);
         TextSummary {
             len: self.len(),
+            chars,
             len_utf16: self.len_utf16(),
             lines: self.lines(),
             first_line_chars: self.first_line_chars(),
@@ -229,16 +231,19 @@ impl<'a> ChunkSlice<'a> {
     }
 
     /// Get the longest row in the chunk and its length in characters.
+    /// Calculate the total number of characters in the chunk along the way.
     #[inline(always)]
-    pub fn longest_row(&self) -> (u32, u32) {
+    pub fn longest_row(&self, total_chars: &mut usize) -> (u32, u32) {
         let mut chars = self.chars;
         let mut newlines = self.newlines;
+        *total_chars = 0;
         let mut row = 0;
         let mut longest_row = 0;
         let mut longest_row_chars = 0;
         while newlines > 0 {
             let newline_ix = newlines.trailing_zeros();
             let row_chars = (chars & ((1 << newline_ix) - 1)).count_ones() as u8;
+            *total_chars += usize::from(row_chars);
             if row_chars > longest_row_chars {
                 longest_row = row;
                 longest_row_chars = row_chars;
@@ -249,9 +254,11 @@ impl<'a> ChunkSlice<'a> {
             chars >>= newline_ix;
             chars >>= 1;
             row += 1;
+            *total_chars += 1;
         }
 
         let row_chars = chars.count_ones() as u8;
+        *total_chars += usize::from(row_chars);
         if row_chars > longest_row_chars {
             (row, row_chars as u32)
         } else {
@@ -908,7 +915,7 @@ mod tests {
         }
 
         // Verify longest row
-        let (longest_row, longest_chars) = chunk.longest_row();
+        let (longest_row, longest_chars) = chunk.longest_row(&mut 0);
         let mut max_chars = 0;
         let mut current_row = 0;
         let mut current_chars = 0;

crates/rope/src/rope.rs 🔗

@@ -965,8 +965,10 @@ impl sum_tree::Summary for ChunkSummary {
 /// Summary of a string of text.
 #[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
 pub struct TextSummary {
-    /// Length in UTF-8
+    /// Length in bytes.
     pub len: usize,
+    /// Length in UTF-8.
+    pub chars: usize,
     /// Length in UTF-16 code units
     pub len_utf16: OffsetUtf16,
     /// A point representing the number of lines and the length of the last line
@@ -994,6 +996,7 @@ impl TextSummary {
     pub fn newline() -> Self {
         Self {
             len: 1,
+            chars: 1,
             len_utf16: OffsetUtf16(1),
             first_line_chars: 0,
             last_line_chars: 0,
@@ -1022,7 +1025,9 @@ impl<'a> From<&'a str> for TextSummary {
         let mut last_line_len_utf16 = 0;
         let mut longest_row = 0;
         let mut longest_row_chars = 0;
+        let mut chars = 0;
         for c in text.chars() {
+            chars += 1;
             len_utf16.0 += c.len_utf16();
 
             if c == '\n' {
@@ -1047,6 +1052,7 @@ impl<'a> From<&'a str> for TextSummary {
 
         TextSummary {
             len: text.len(),
+            chars,
             len_utf16,
             lines,
             first_line_chars,
@@ -1103,6 +1109,7 @@ impl<'a> ops::AddAssign<&'a Self> for TextSummary {
             self.last_line_len_utf16 = other.last_line_len_utf16;
         }
 
+        self.chars += other.chars;
         self.len += other.len;
         self.len_utf16 += other.len_utf16;
         self.lines += other.lines;

crates/text/src/tests.rs 🔗

@@ -261,10 +261,25 @@ fn test_text_summary_for_range() {
         BufferId::new(1).unwrap(),
         "ab\nefg\nhklm\nnopqrs\ntuvwxyz".into(),
     );
+    assert_eq!(
+        buffer.text_summary_for_range::<TextSummary, _>(0..2),
+        TextSummary {
+            len: 2,
+            chars: 2,
+            len_utf16: OffsetUtf16(2),
+            lines: Point::new(0, 2),
+            first_line_chars: 2,
+            last_line_chars: 2,
+            last_line_len_utf16: 2,
+            longest_row: 0,
+            longest_row_chars: 2,
+        }
+    );
     assert_eq!(
         buffer.text_summary_for_range::<TextSummary, _>(1..3),
         TextSummary {
             len: 2,
+            chars: 2,
             len_utf16: OffsetUtf16(2),
             lines: Point::new(1, 0),
             first_line_chars: 1,
@@ -278,6 +293,7 @@ fn test_text_summary_for_range() {
         buffer.text_summary_for_range::<TextSummary, _>(1..12),
         TextSummary {
             len: 11,
+            chars: 11,
             len_utf16: OffsetUtf16(11),
             lines: Point::new(3, 0),
             first_line_chars: 1,
@@ -291,6 +307,7 @@ fn test_text_summary_for_range() {
         buffer.text_summary_for_range::<TextSummary, _>(0..20),
         TextSummary {
             len: 20,
+            chars: 20,
             len_utf16: OffsetUtf16(20),
             lines: Point::new(4, 1),
             first_line_chars: 2,
@@ -304,6 +321,7 @@ fn test_text_summary_for_range() {
         buffer.text_summary_for_range::<TextSummary, _>(0..22),
         TextSummary {
             len: 22,
+            chars: 22,
             len_utf16: OffsetUtf16(22),
             lines: Point::new(4, 3),
             first_line_chars: 2,
@@ -317,6 +335,7 @@ fn test_text_summary_for_range() {
         buffer.text_summary_for_range::<TextSummary, _>(7..22),
         TextSummary {
             len: 15,
+            chars: 15,
             len_utf16: OffsetUtf16(15),
             lines: Point::new(2, 3),
             first_line_chars: 4,