From da2bd4b8e9209088a28f76e993e79af89aebb7f3 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Sat, 25 Jan 2025 21:24:19 +0200 Subject: [PATCH] Rework go to line infrastructure (#23654) 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: image After: image * 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 --- 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(-) diff --git a/crates/go_to_line/src/cursor_position.rs b/crates/go_to_line/src/cursor_position.rs index ed5ed4525d..b25ccea49f 100644 --- a/crates/go_to_line/src/cursor_position.rs +++ b/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, selected_count: SelectionStats, context: Option, workspace: WeakView, @@ -28,6 +28,30 @@ pub struct CursorPosition { _observe_active_editor: Option, } +/// 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, 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::(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::>; - let buffer = editor.buffer().read(cx).snapshot(cx); - if buffer.excerpts().count() > 0 { - for selection in editor.selections.all::(cx) { - cursor_position.selected_count.characters += buffer - .text_for_range(selection.start..selection.end) - .map(|t| t.chars().count()) - .sum::(); - if last_selection.as_ref().map_or(true, |last_selection| { - selection.id > last_selection.id - }) { - last_selection = Some(selection); - } - } + let mut last_selection = None::>; + let snapshot = editor.buffer().read(cx).snapshot(cx); + if snapshot.excerpts().count() > 0 { for selection in editor.selections.all::(cx) { + let selection_summary = snapshot + .text_summary_for_range::( + 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 { + self.position + } } impl Render for CursorPosition { fn render(&mut self, cx: &mut ViewContext) -> 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); diff --git a/crates/go_to_line/src/go_to_line.rs b/crates/go_to_line/src/go_to_line.rs index acbcf5ee9d..9b304b896f 100644 --- a/crates/go_to_line/src/go_to_line.rs +++ b/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, active_editor: View, - active_buffer: Model, current_text: SharedString, prev_scroll_position: Option>, _subscriptions: Vec, @@ -67,10 +68,13 @@ impl GoToLine { active_buffer: Model, cx: &mut ViewContext, ) -> Self { - let (cursor, last_line, scroll_position) = active_editor.update(cx, |editor, cx| { - let cursor = editor.selections.last::(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::(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::({ + 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.active_editor.update(cx, |editor, cx| { editor.clear_row_highlights::(); - 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::( start..end, @@ -162,25 +188,49 @@ impl GoToLine { fn anchor_from_query( &self, - multibuffer: &MultiBuffer, + snapshot: &MultiBufferSnapshot, cx: &ViewContext, ) -> Option { - 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, Option) { + fn line_and_char_from_query(&self, cx: &AppContext) -> Option<(u32, Option)> { 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::().ok()); + let row = components.next().and_then(|row| row.parse::().ok())?; let column = components.next().and_then(|col| col.parse::().ok()); - (row, column) + Some((row, column)) } fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { @@ -189,8 +239,8 @@ impl GoToLine { fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { 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) -> 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::() + .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::() + .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, + cx: &mut VisualTestContext, + ) -> UserCaretPosition { + workspace.update(cx, |workspace, cx| { + workspace + .status_bar() + .read(cx) + .item_of_type::() + .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, + 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, cx: &mut VisualTestContext, diff --git a/crates/rope/src/chunk.rs b/crates/rope/src/chunk.rs index 5c2b9b87c3..b6ddc69c00 100644 --- a/crates/rope/src/chunk.rs +++ b/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; diff --git a/crates/rope/src/rope.rs b/crates/rope/src/rope.rs index 95b313082a..b384b2fc5c 100644 --- a/crates/rope/src/rope.rs +++ b/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; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 4debfa6292..3338fc5c00 100644 --- a/crates/text/src/tests.rs +++ b/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::(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::(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::(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::(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::(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::(7..22), TextSummary { len: 15, + chars: 15, len_utf16: OffsetUtf16(15), lines: Point::new(2, 3), first_line_chars: 4,