Files
zed/crates/editor/src/display_map/wrap_map.rs
T
b8c30f448f Improve Tab Map performance (#32243)
## Context

While looking into: #32051 and #16120 with instruments, I noticed that
`TabSnapshot::to_tab_point` and `TabSnapshot::to_fold_point` are a
common bottleneck between the two issues. This PR takes the first steps
into closing the stated issues by improving the performance of both
those functions.

### Method

`to_tab_point` and `to_fold_point` iterate through each character in
their rows to find tab characters and translate those characters into
their respective transformations. This PR changes this iteration to take
advantage of the tab character bitmap in the `Rope` data structure and
goes directly to each tab character when iterating.

The tab bitmap is now passed from each layer in-between the `Rope` to
the `TabMap`.

### Testing 

I added several randomized tests to ensure that the new `to_tab_point`
and `to_fold_point` functions have the same behavior as the old methods
they're replacing. I also added `test_random_chunk_bitmap` on each layer
the tab bitmap is passed up to the `TabMap` to make sure that the bitmap
being passed is transformed correctly between the layers of
`DisplayMap`.

`test_random_chunk_bitmap` was added to these layers:
- buffer
- multi buffer
- custom_highlights
- inlay_map
- fold_map

## Benchmarking 

I setup benchmarks with criterion that is runnable via `cargo bench -p
editor --profile=release-fast`. When benchmarking I had my laptop
plugged in and did so from the terminal with a minimal amount of
processes running. I'm also on a m4 max

### Results 

#### To Tab Point

Went from completing 6.8M iterations in 5s with an average time of
`736.13 ns` to `683.38 ns` which is a `-7.1875%` improvement

#### To Fold Point

Went from completing 6.8M iterations in 5s with an average time of
`736.55 ns` to `682.40 ns` which is a `-7.1659%` improvement

#### Editor render 

Went from having an average render time of `62.561 µs` to `57.216 µs`
which is a `-8.8248%` improvement

#### Build Buffer with one long line

Went from having an average buffer build time of `3.2549 ms` to `3.2635
ms` which is a `+0.2151%` regression within the margin of error

#### Editor with 1000 multi cursor input 

Went from having an average edit time of `133.05 ms` to `122.96 ms`
which is a `-7.5776%` improvement

Release Notes:

- N/A

---------

Co-authored-by: Remco Smits <djsmits12@gmail.com>
Co-authored-by: Cole Miller <cole@zed.dev>
Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com>
2025-09-10 16:13:41 -04:00

1529 lines
56 KiB
Rust

use super::{
Highlights,
fold_map::{Chunk, FoldRows},
tab_map::{self, TabEdit, TabPoint, TabSnapshot},
};
use gpui::{App, AppContext as _, Context, Entity, Font, LineWrapper, Pixels, Task};
use language::Point;
use multi_buffer::{MultiBufferSnapshot, RowInfo};
use smol::future::yield_now;
use std::sync::LazyLock;
use std::{cmp, collections::VecDeque, mem, ops::Range, time::Duration};
use sum_tree::{Bias, Cursor, Dimensions, SumTree};
use text::Patch;
pub use super::tab_map::TextSummary;
pub type WrapEdit = text::Edit<u32>;
/// Handles soft wrapping of text.
///
/// See the [`display_map` module documentation](crate::display_map) for more information.
pub struct WrapMap {
snapshot: WrapSnapshot,
pending_edits: VecDeque<(TabSnapshot, Vec<TabEdit>)>,
interpolated_edits: Patch<u32>,
edits_since_sync: Patch<u32>,
wrap_width: Option<Pixels>,
background_task: Option<Task<()>>,
font_with_size: (Font, Pixels),
}
#[derive(Clone)]
pub struct WrapSnapshot {
tab_snapshot: TabSnapshot,
transforms: SumTree<Transform>,
interpolated: bool,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct Transform {
summary: TransformSummary,
display_text: Option<&'static str>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct TransformSummary {
input: TextSummary,
output: TextSummary,
}
#[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)]
pub struct WrapPoint(pub Point);
pub struct WrapChunks<'a> {
input_chunks: tab_map::TabChunks<'a>,
input_chunk: Chunk<'a>,
output_position: WrapPoint,
max_output_row: u32,
transforms: Cursor<'a, Transform, Dimensions<WrapPoint, TabPoint>>,
snapshot: &'a WrapSnapshot,
}
#[derive(Clone)]
pub struct WrapRows<'a> {
input_buffer_rows: FoldRows<'a>,
input_buffer_row: RowInfo,
output_row: u32,
soft_wrapped: bool,
max_output_row: u32,
transforms: Cursor<'a, Transform, Dimensions<WrapPoint, TabPoint>>,
}
impl WrapRows<'_> {
pub(crate) fn seek(&mut self, start_row: u32) {
self.transforms
.seek(&WrapPoint::new(start_row, 0), Bias::Left);
let mut input_row = self.transforms.start().1.row();
if self.transforms.item().is_some_and(|t| t.is_isomorphic()) {
input_row += start_row - self.transforms.start().0.row();
}
self.soft_wrapped = self.transforms.item().is_some_and(|t| !t.is_isomorphic());
self.input_buffer_rows.seek(input_row);
self.input_buffer_row = self.input_buffer_rows.next().unwrap();
self.output_row = start_row;
}
}
impl WrapMap {
pub fn new(
tab_snapshot: TabSnapshot,
font: Font,
font_size: Pixels,
wrap_width: Option<Pixels>,
cx: &mut App,
) -> (Entity<Self>, WrapSnapshot) {
let handle = cx.new(|cx| {
let mut this = Self {
font_with_size: (font, font_size),
wrap_width: None,
pending_edits: Default::default(),
interpolated_edits: Default::default(),
edits_since_sync: Default::default(),
snapshot: WrapSnapshot::new(tab_snapshot),
background_task: None,
};
this.set_wrap_width(wrap_width, cx);
mem::take(&mut this.edits_since_sync);
this
});
let snapshot = handle.read(cx).snapshot.clone();
(handle, snapshot)
}
#[cfg(test)]
pub fn is_rewrapping(&self) -> bool {
self.background_task.is_some()
}
pub fn sync(
&mut self,
tab_snapshot: TabSnapshot,
edits: Vec<TabEdit>,
cx: &mut Context<Self>,
) -> (WrapSnapshot, Patch<u32>) {
if self.wrap_width.is_some() {
self.pending_edits.push_back((tab_snapshot, edits));
self.flush_edits(cx);
} else {
self.edits_since_sync = self
.edits_since_sync
.compose(self.snapshot.interpolate(tab_snapshot, &edits));
self.snapshot.interpolated = false;
}
(self.snapshot.clone(), mem::take(&mut self.edits_since_sync))
}
pub fn set_font_with_size(
&mut self,
font: Font,
font_size: Pixels,
cx: &mut Context<Self>,
) -> bool {
let font_with_size = (font, font_size);
if font_with_size == self.font_with_size {
false
} else {
self.font_with_size = font_with_size;
self.rewrap(cx);
true
}
}
pub fn set_wrap_width(&mut self, wrap_width: Option<Pixels>, cx: &mut Context<Self>) -> bool {
if wrap_width == self.wrap_width {
return false;
}
self.wrap_width = wrap_width;
self.rewrap(cx);
true
}
fn rewrap(&mut self, cx: &mut Context<Self>) {
self.background_task.take();
self.interpolated_edits.clear();
self.pending_edits.clear();
if let Some(wrap_width) = self.wrap_width {
let mut new_snapshot = self.snapshot.clone();
let text_system = cx.text_system().clone();
let (font, font_size) = self.font_with_size.clone();
let task = cx.background_spawn(async move {
let mut line_wrapper = text_system.line_wrapper(font, font_size);
let tab_snapshot = new_snapshot.tab_snapshot.clone();
let range = TabPoint::zero()..tab_snapshot.max_point();
let edits = new_snapshot
.update(
tab_snapshot,
&[TabEdit {
old: range.clone(),
new: range.clone(),
}],
wrap_width,
&mut line_wrapper,
)
.await;
(new_snapshot, edits)
});
match cx
.background_executor()
.block_with_timeout(Duration::from_millis(5), task)
{
Ok((snapshot, edits)) => {
self.snapshot = snapshot;
self.edits_since_sync = self.edits_since_sync.compose(&edits);
}
Err(wrap_task) => {
self.background_task = Some(cx.spawn(async move |this, cx| {
let (snapshot, edits) = wrap_task.await;
this.update(cx, |this, cx| {
this.snapshot = snapshot;
this.edits_since_sync = this
.edits_since_sync
.compose(mem::take(&mut this.interpolated_edits).invert())
.compose(&edits);
this.background_task = None;
this.flush_edits(cx);
cx.notify();
})
.ok();
}));
}
}
} else {
let old_rows = self.snapshot.transforms.summary().output.lines.row + 1;
self.snapshot.transforms = SumTree::default();
let summary = self.snapshot.tab_snapshot.text_summary();
if !summary.lines.is_zero() {
self.snapshot
.transforms
.push(Transform::isomorphic(summary), &());
}
let new_rows = self.snapshot.transforms.summary().output.lines.row + 1;
self.snapshot.interpolated = false;
self.edits_since_sync = self.edits_since_sync.compose(Patch::new(vec![WrapEdit {
old: 0..old_rows,
new: 0..new_rows,
}]));
}
}
fn flush_edits(&mut self, cx: &mut Context<Self>) {
if !self.snapshot.interpolated {
let mut to_remove_len = 0;
for (tab_snapshot, _) in &self.pending_edits {
if tab_snapshot.version <= self.snapshot.tab_snapshot.version {
to_remove_len += 1;
} else {
break;
}
}
self.pending_edits.drain(..to_remove_len);
}
if self.pending_edits.is_empty() {
return;
}
if let Some(wrap_width) = self.wrap_width
&& self.background_task.is_none()
{
let pending_edits = self.pending_edits.clone();
let mut snapshot = self.snapshot.clone();
let text_system = cx.text_system().clone();
let (font, font_size) = self.font_with_size.clone();
let update_task = cx.background_spawn(async move {
let mut edits = Patch::default();
let mut line_wrapper = text_system.line_wrapper(font, font_size);
for (tab_snapshot, tab_edits) in pending_edits {
let wrap_edits = snapshot
.update(tab_snapshot, &tab_edits, wrap_width, &mut line_wrapper)
.await;
edits = edits.compose(&wrap_edits);
}
(snapshot, edits)
});
match cx
.background_executor()
.block_with_timeout(Duration::from_millis(1), update_task)
{
Ok((snapshot, output_edits)) => {
self.snapshot = snapshot;
self.edits_since_sync = self.edits_since_sync.compose(&output_edits);
}
Err(update_task) => {
self.background_task = Some(cx.spawn(async move |this, cx| {
let (snapshot, edits) = update_task.await;
this.update(cx, |this, cx| {
this.snapshot = snapshot;
this.edits_since_sync = this
.edits_since_sync
.compose(mem::take(&mut this.interpolated_edits).invert())
.compose(&edits);
this.background_task = None;
this.flush_edits(cx);
cx.notify();
})
.ok();
}));
}
}
}
let was_interpolated = self.snapshot.interpolated;
let mut to_remove_len = 0;
for (tab_snapshot, edits) in &self.pending_edits {
if tab_snapshot.version <= self.snapshot.tab_snapshot.version {
to_remove_len += 1;
} else {
let interpolated_edits = self.snapshot.interpolate(tab_snapshot.clone(), edits);
self.edits_since_sync = self.edits_since_sync.compose(&interpolated_edits);
self.interpolated_edits = self.interpolated_edits.compose(&interpolated_edits);
}
}
if !was_interpolated {
self.pending_edits.drain(..to_remove_len);
}
}
}
impl WrapSnapshot {
fn new(tab_snapshot: TabSnapshot) -> Self {
let mut transforms = SumTree::default();
let extent = tab_snapshot.text_summary();
if !extent.lines.is_zero() {
transforms.push(Transform::isomorphic(extent), &());
}
Self {
transforms,
tab_snapshot,
interpolated: true,
}
}
pub fn buffer_snapshot(&self) -> &MultiBufferSnapshot {
self.tab_snapshot.buffer_snapshot()
}
fn interpolate(&mut self, new_tab_snapshot: TabSnapshot, tab_edits: &[TabEdit]) -> Patch<u32> {
let mut new_transforms;
if tab_edits.is_empty() {
new_transforms = self.transforms.clone();
} else {
let mut old_cursor = self.transforms.cursor::<TabPoint>(&());
let mut tab_edits_iter = tab_edits.iter().peekable();
new_transforms =
old_cursor.slice(&tab_edits_iter.peek().unwrap().old.start, Bias::Right);
while let Some(edit) = tab_edits_iter.next() {
if edit.new.start > TabPoint::from(new_transforms.summary().input.lines) {
let summary = new_tab_snapshot.text_summary_for_range(
TabPoint::from(new_transforms.summary().input.lines)..edit.new.start,
);
new_transforms.push_or_extend(Transform::isomorphic(summary));
}
if !edit.new.is_empty() {
new_transforms.push_or_extend(Transform::isomorphic(
new_tab_snapshot.text_summary_for_range(edit.new.clone()),
));
}
old_cursor.seek_forward(&edit.old.end, Bias::Right);
if let Some(next_edit) = tab_edits_iter.peek() {
if next_edit.old.start > old_cursor.end() {
if old_cursor.end() > edit.old.end {
let summary = self
.tab_snapshot
.text_summary_for_range(edit.old.end..old_cursor.end());
new_transforms.push_or_extend(Transform::isomorphic(summary));
}
old_cursor.next();
new_transforms
.append(old_cursor.slice(&next_edit.old.start, Bias::Right), &());
}
} else {
if old_cursor.end() > edit.old.end {
let summary = self
.tab_snapshot
.text_summary_for_range(edit.old.end..old_cursor.end());
new_transforms.push_or_extend(Transform::isomorphic(summary));
}
old_cursor.next();
new_transforms.append(old_cursor.suffix(), &());
}
}
}
let old_snapshot = mem::replace(
self,
WrapSnapshot {
tab_snapshot: new_tab_snapshot,
transforms: new_transforms,
interpolated: true,
},
);
self.check_invariants();
old_snapshot.compute_edits(tab_edits, self)
}
async fn update(
&mut self,
new_tab_snapshot: TabSnapshot,
tab_edits: &[TabEdit],
wrap_width: Pixels,
line_wrapper: &mut LineWrapper,
) -> Patch<u32> {
#[derive(Debug)]
struct RowEdit {
old_rows: Range<u32>,
new_rows: Range<u32>,
}
let mut tab_edits_iter = tab_edits.iter().peekable();
let mut row_edits = Vec::with_capacity(tab_edits.len());
while let Some(edit) = tab_edits_iter.next() {
let mut row_edit = RowEdit {
old_rows: edit.old.start.row()..edit.old.end.row() + 1,
new_rows: edit.new.start.row()..edit.new.end.row() + 1,
};
while let Some(next_edit) = tab_edits_iter.peek() {
if next_edit.old.start.row() <= row_edit.old_rows.end {
row_edit.old_rows.end = next_edit.old.end.row() + 1;
row_edit.new_rows.end = next_edit.new.end.row() + 1;
tab_edits_iter.next();
} else {
break;
}
}
row_edits.push(row_edit);
}
let mut new_transforms;
if row_edits.is_empty() {
new_transforms = self.transforms.clone();
} else {
let mut row_edits = row_edits.into_iter().peekable();
let mut old_cursor = self.transforms.cursor::<TabPoint>(&());
new_transforms = old_cursor.slice(
&TabPoint::new(row_edits.peek().unwrap().old_rows.start, 0),
Bias::Right,
);
while let Some(edit) = row_edits.next() {
if edit.new_rows.start > new_transforms.summary().input.lines.row {
let summary = new_tab_snapshot.text_summary_for_range(
TabPoint(new_transforms.summary().input.lines)
..TabPoint::new(edit.new_rows.start, 0),
);
new_transforms.push_or_extend(Transform::isomorphic(summary));
}
let mut line = String::new();
let mut line_fragments = Vec::new();
let mut remaining = None;
let mut chunks = new_tab_snapshot.chunks(
TabPoint::new(edit.new_rows.start, 0)..new_tab_snapshot.max_point(),
false,
Highlights::default(),
);
let mut edit_transforms = Vec::<Transform>::new();
for _ in edit.new_rows.start..edit.new_rows.end {
while let Some(chunk) = remaining.take().or_else(|| chunks.next()) {
if let Some(ix) = chunk.text.find('\n') {
let (prefix, suffix) = chunk.text.split_at(ix + 1);
line_fragments.push(gpui::LineFragment::text(prefix));
line.push_str(prefix);
remaining = Some(Chunk {
text: suffix,
..chunk
});
break;
} else {
if let Some(width) =
chunk.renderer.as_ref().and_then(|r| r.measured_width)
{
line_fragments
.push(gpui::LineFragment::element(width, chunk.text.len()));
} else {
line_fragments.push(gpui::LineFragment::text(chunk.text));
}
line.push_str(chunk.text);
}
}
if line.is_empty() {
break;
}
let mut prev_boundary_ix = 0;
for boundary in line_wrapper.wrap_line(&line_fragments, wrap_width) {
let wrapped = &line[prev_boundary_ix..boundary.ix];
push_isomorphic(&mut edit_transforms, TextSummary::from(wrapped));
edit_transforms.push(Transform::wrap(boundary.next_indent));
prev_boundary_ix = boundary.ix;
}
if prev_boundary_ix < line.len() {
push_isomorphic(
&mut edit_transforms,
TextSummary::from(&line[prev_boundary_ix..]),
);
}
line.clear();
line_fragments.clear();
yield_now().await;
}
let mut edit_transforms = edit_transforms.into_iter();
if let Some(transform) = edit_transforms.next() {
new_transforms.push_or_extend(transform);
}
new_transforms.extend(edit_transforms, &());
old_cursor.seek_forward(&TabPoint::new(edit.old_rows.end, 0), Bias::Right);
if let Some(next_edit) = row_edits.peek() {
if next_edit.old_rows.start > old_cursor.end().row() {
if old_cursor.end() > TabPoint::new(edit.old_rows.end, 0) {
let summary = self.tab_snapshot.text_summary_for_range(
TabPoint::new(edit.old_rows.end, 0)..old_cursor.end(),
);
new_transforms.push_or_extend(Transform::isomorphic(summary));
}
old_cursor.next();
new_transforms.append(
old_cursor
.slice(&TabPoint::new(next_edit.old_rows.start, 0), Bias::Right),
&(),
);
}
} else {
if old_cursor.end() > TabPoint::new(edit.old_rows.end, 0) {
let summary = self.tab_snapshot.text_summary_for_range(
TabPoint::new(edit.old_rows.end, 0)..old_cursor.end(),
);
new_transforms.push_or_extend(Transform::isomorphic(summary));
}
old_cursor.next();
new_transforms.append(old_cursor.suffix(), &());
}
}
}
let old_snapshot = mem::replace(
self,
WrapSnapshot {
tab_snapshot: new_tab_snapshot,
transforms: new_transforms,
interpolated: false,
},
);
self.check_invariants();
old_snapshot.compute_edits(tab_edits, self)
}
fn compute_edits(&self, tab_edits: &[TabEdit], new_snapshot: &WrapSnapshot) -> Patch<u32> {
let mut wrap_edits = Vec::with_capacity(tab_edits.len());
let mut old_cursor = self.transforms.cursor::<TransformSummary>(&());
let mut new_cursor = new_snapshot.transforms.cursor::<TransformSummary>(&());
for mut tab_edit in tab_edits.iter().cloned() {
tab_edit.old.start.0.column = 0;
tab_edit.old.end.0 += Point::new(1, 0);
tab_edit.new.start.0.column = 0;
tab_edit.new.end.0 += Point::new(1, 0);
old_cursor.seek(&tab_edit.old.start, Bias::Right);
let mut old_start = old_cursor.start().output.lines;
old_start += tab_edit.old.start.0 - old_cursor.start().input.lines;
old_cursor.seek(&tab_edit.old.end, Bias::Right);
let mut old_end = old_cursor.start().output.lines;
old_end += tab_edit.old.end.0 - old_cursor.start().input.lines;
new_cursor.seek(&tab_edit.new.start, Bias::Right);
let mut new_start = new_cursor.start().output.lines;
new_start += tab_edit.new.start.0 - new_cursor.start().input.lines;
new_cursor.seek(&tab_edit.new.end, Bias::Right);
let mut new_end = new_cursor.start().output.lines;
new_end += tab_edit.new.end.0 - new_cursor.start().input.lines;
wrap_edits.push(WrapEdit {
old: old_start.row..old_end.row,
new: new_start.row..new_end.row,
});
}
wrap_edits = consolidate_wrap_edits(wrap_edits);
Patch::new(wrap_edits)
}
pub(crate) fn chunks<'a>(
&'a self,
rows: Range<u32>,
language_aware: bool,
highlights: Highlights<'a>,
) -> WrapChunks<'a> {
let output_start = WrapPoint::new(rows.start, 0);
let output_end = WrapPoint::new(rows.end, 0);
let mut transforms = self
.transforms
.cursor::<Dimensions<WrapPoint, TabPoint>>(&());
transforms.seek(&output_start, Bias::Right);
let mut input_start = TabPoint(transforms.start().1.0);
if transforms.item().is_some_and(|t| t.is_isomorphic()) {
input_start.0 += output_start.0 - transforms.start().0.0;
}
let input_end = self
.to_tab_point(output_end)
.min(self.tab_snapshot.max_point());
WrapChunks {
input_chunks: self.tab_snapshot.chunks(
input_start..input_end,
language_aware,
highlights,
),
input_chunk: Default::default(),
output_position: output_start,
max_output_row: rows.end,
transforms,
snapshot: self,
}
}
pub fn max_point(&self) -> WrapPoint {
WrapPoint(self.transforms.summary().output.lines)
}
pub fn line_len(&self, row: u32) -> u32 {
let mut cursor = self
.transforms
.cursor::<Dimensions<WrapPoint, TabPoint>>(&());
cursor.seek(&WrapPoint::new(row + 1, 0), Bias::Left);
if cursor
.item()
.is_some_and(|transform| transform.is_isomorphic())
{
let overshoot = row - cursor.start().0.row();
let tab_row = cursor.start().1.row() + overshoot;
let tab_line_len = self.tab_snapshot.line_len(tab_row);
if overshoot == 0 {
cursor.start().0.column() + (tab_line_len - cursor.start().1.column())
} else {
tab_line_len
}
} else {
cursor.start().0.column()
}
}
pub fn text_summary_for_range(&self, rows: Range<u32>) -> TextSummary {
let mut summary = TextSummary::default();
let start = WrapPoint::new(rows.start, 0);
let end = WrapPoint::new(rows.end, 0);
let mut cursor = self
.transforms
.cursor::<Dimensions<WrapPoint, TabPoint>>(&());
cursor.seek(&start, Bias::Right);
if let Some(transform) = cursor.item() {
let start_in_transform = start.0 - cursor.start().0.0;
let end_in_transform = cmp::min(end, cursor.end().0).0 - cursor.start().0.0;
if transform.is_isomorphic() {
let tab_start = TabPoint(cursor.start().1.0 + start_in_transform);
let tab_end = TabPoint(cursor.start().1.0 + end_in_transform);
summary += &self.tab_snapshot.text_summary_for_range(tab_start..tab_end);
} else {
debug_assert_eq!(start_in_transform.row, end_in_transform.row);
let indent_len = end_in_transform.column - start_in_transform.column;
summary += &TextSummary {
lines: Point::new(0, indent_len),
first_line_chars: indent_len,
last_line_chars: indent_len,
longest_row: 0,
longest_row_chars: indent_len,
};
}
cursor.next();
}
if rows.end > cursor.start().0.row() {
summary += &cursor
.summary::<_, TransformSummary>(&WrapPoint::new(rows.end, 0), Bias::Right)
.output;
if let Some(transform) = cursor.item() {
let end_in_transform = end.0 - cursor.start().0.0;
if transform.is_isomorphic() {
let char_start = cursor.start().1;
let char_end = TabPoint(char_start.0 + end_in_transform);
summary += &self
.tab_snapshot
.text_summary_for_range(char_start..char_end);
} else {
debug_assert_eq!(end_in_transform, Point::new(1, 0));
summary += &TextSummary {
lines: Point::new(1, 0),
first_line_chars: 0,
last_line_chars: 0,
longest_row: 0,
longest_row_chars: 0,
};
}
}
}
summary
}
pub fn soft_wrap_indent(&self, row: u32) -> Option<u32> {
let mut cursor = self.transforms.cursor::<WrapPoint>(&());
cursor.seek(&WrapPoint::new(row + 1, 0), Bias::Right);
cursor.item().and_then(|transform| {
if transform.is_isomorphic() {
None
} else {
Some(transform.summary.output.lines.column)
}
})
}
pub fn longest_row(&self) -> u32 {
self.transforms.summary().output.longest_row
}
pub fn row_infos(&self, start_row: u32) -> WrapRows<'_> {
let mut transforms = self
.transforms
.cursor::<Dimensions<WrapPoint, TabPoint>>(&());
transforms.seek(&WrapPoint::new(start_row, 0), Bias::Left);
let mut input_row = transforms.start().1.row();
if transforms.item().is_some_and(|t| t.is_isomorphic()) {
input_row += start_row - transforms.start().0.row();
}
let soft_wrapped = transforms.item().is_some_and(|t| !t.is_isomorphic());
let mut input_buffer_rows = self.tab_snapshot.rows(input_row);
let input_buffer_row = input_buffer_rows.next().unwrap();
WrapRows {
transforms,
input_buffer_row,
input_buffer_rows,
output_row: start_row,
soft_wrapped,
max_output_row: self.max_point().row(),
}
}
pub fn to_tab_point(&self, point: WrapPoint) -> TabPoint {
let mut cursor = self
.transforms
.cursor::<Dimensions<WrapPoint, TabPoint>>(&());
cursor.seek(&point, Bias::Right);
let mut tab_point = cursor.start().1.0;
if cursor.item().is_some_and(|t| t.is_isomorphic()) {
tab_point += point.0 - cursor.start().0.0;
}
TabPoint(tab_point)
}
pub fn to_point(&self, point: WrapPoint, bias: Bias) -> Point {
self.tab_snapshot.to_point(self.to_tab_point(point), bias)
}
pub fn make_wrap_point(&self, point: Point, bias: Bias) -> WrapPoint {
self.tab_point_to_wrap_point(self.tab_snapshot.make_tab_point(point, bias))
}
pub fn tab_point_to_wrap_point(&self, point: TabPoint) -> WrapPoint {
let mut cursor = self
.transforms
.cursor::<Dimensions<TabPoint, WrapPoint>>(&());
cursor.seek(&point, Bias::Right);
WrapPoint(cursor.start().1.0 + (point.0 - cursor.start().0.0))
}
pub fn clip_point(&self, mut point: WrapPoint, bias: Bias) -> WrapPoint {
if bias == Bias::Left {
let mut cursor = self.transforms.cursor::<WrapPoint>(&());
cursor.seek(&point, Bias::Right);
if cursor.item().is_some_and(|t| !t.is_isomorphic()) {
point = *cursor.start();
*point.column_mut() -= 1;
}
}
self.tab_point_to_wrap_point(self.tab_snapshot.clip_point(self.to_tab_point(point), bias))
}
pub fn prev_row_boundary(&self, mut point: WrapPoint) -> u32 {
if self.transforms.is_empty() {
return 0;
}
*point.column_mut() = 0;
let mut cursor = self
.transforms
.cursor::<Dimensions<WrapPoint, TabPoint>>(&());
cursor.seek(&point, Bias::Right);
if cursor.item().is_none() {
cursor.prev();
}
while let Some(transform) = cursor.item() {
if transform.is_isomorphic() && cursor.start().1.column() == 0 {
return cmp::min(cursor.end().0.row(), point.row());
} else {
cursor.prev();
}
}
unreachable!()
}
pub fn next_row_boundary(&self, mut point: WrapPoint) -> Option<u32> {
point.0 += Point::new(1, 0);
let mut cursor = self
.transforms
.cursor::<Dimensions<WrapPoint, TabPoint>>(&());
cursor.seek(&point, Bias::Right);
while let Some(transform) = cursor.item() {
if transform.is_isomorphic() && cursor.start().1.column() == 0 {
return Some(cmp::max(cursor.start().0.row(), point.row()));
} else {
cursor.next();
}
}
None
}
#[cfg(test)]
pub fn text(&self) -> String {
self.text_chunks(0).collect()
}
#[cfg(test)]
pub fn text_chunks(&self, wrap_row: u32) -> impl Iterator<Item = &str> {
self.chunks(
wrap_row..self.max_point().row() + 1,
false,
Highlights::default(),
)
.map(|h| h.text)
}
fn check_invariants(&self) {
#[cfg(test)]
{
assert_eq!(
TabPoint::from(self.transforms.summary().input.lines),
self.tab_snapshot.max_point()
);
{
let mut transforms = self.transforms.cursor::<()>(&()).peekable();
while let Some(transform) = transforms.next() {
if let Some(next_transform) = transforms.peek() {
assert!(transform.is_isomorphic() != next_transform.is_isomorphic());
}
}
}
let text = language::Rope::from(self.text().as_str());
let mut input_buffer_rows = self.tab_snapshot.rows(0);
let mut expected_buffer_rows = Vec::new();
let mut prev_tab_row = 0;
for display_row in 0..=self.max_point().row() {
let tab_point = self.to_tab_point(WrapPoint::new(display_row, 0));
if tab_point.row() == prev_tab_row && display_row != 0 {
expected_buffer_rows.push(None);
} else {
expected_buffer_rows.push(input_buffer_rows.next().unwrap().buffer_row);
}
prev_tab_row = tab_point.row();
assert_eq!(self.line_len(display_row), text.line_len(display_row));
}
for start_display_row in 0..expected_buffer_rows.len() {
assert_eq!(
self.row_infos(start_display_row as u32)
.map(|row_info| row_info.buffer_row)
.collect::<Vec<_>>(),
&expected_buffer_rows[start_display_row..],
"invalid buffer_rows({}..)",
start_display_row
);
}
}
}
}
impl WrapChunks<'_> {
pub(crate) fn seek(&mut self, rows: Range<u32>) {
let output_start = WrapPoint::new(rows.start, 0);
let output_end = WrapPoint::new(rows.end, 0);
self.transforms.seek(&output_start, Bias::Right);
let mut input_start = TabPoint(self.transforms.start().1.0);
if self.transforms.item().is_some_and(|t| t.is_isomorphic()) {
input_start.0 += output_start.0 - self.transforms.start().0.0;
}
let input_end = self
.snapshot
.to_tab_point(output_end)
.min(self.snapshot.tab_snapshot.max_point());
self.input_chunks.seek(input_start..input_end);
self.input_chunk = Chunk::default();
self.output_position = output_start;
self.max_output_row = rows.end;
}
}
impl<'a> Iterator for WrapChunks<'a> {
type Item = Chunk<'a>;
fn next(&mut self) -> Option<Self::Item> {
if self.output_position.row() >= self.max_output_row {
return None;
}
let transform = self.transforms.item()?;
if let Some(display_text) = transform.display_text {
let mut start_ix = 0;
let mut end_ix = display_text.len();
let mut summary = transform.summary.output.lines;
if self.output_position > self.transforms.start().0 {
// Exclude newline starting prior to the desired row.
start_ix = 1;
summary.row = 0;
} else if self.output_position.row() + 1 >= self.max_output_row {
// Exclude soft indentation ending after the desired row.
end_ix = 1;
summary.column = 0;
}
self.output_position.0 += summary;
self.transforms.next();
return Some(Chunk {
text: &display_text[start_ix..end_ix],
..Default::default()
});
}
if self.input_chunk.text.is_empty() {
self.input_chunk = self.input_chunks.next().unwrap();
}
let mut input_len = 0;
let transform_end = self.transforms.end().0;
for c in self.input_chunk.text.chars() {
let char_len = c.len_utf8();
input_len += char_len;
if c == '\n' {
*self.output_position.row_mut() += 1;
*self.output_position.column_mut() = 0;
} else {
*self.output_position.column_mut() += char_len as u32;
}
if self.output_position >= transform_end {
self.transforms.next();
break;
}
}
let (prefix, suffix) = self.input_chunk.text.split_at(input_len);
let (chars, tabs) = if input_len == 128 {
let output = (self.input_chunk.chars, self.input_chunk.tabs);
self.input_chunk.chars = 0;
self.input_chunk.tabs = 0;
output
} else {
let mask = (1 << input_len) - 1;
let output = (self.input_chunk.chars & mask, self.input_chunk.tabs & mask);
self.input_chunk.chars = self.input_chunk.chars >> input_len;
self.input_chunk.tabs = self.input_chunk.tabs >> input_len;
output
};
self.input_chunk.text = suffix;
Some(Chunk {
text: prefix,
chars,
tabs,
..self.input_chunk.clone()
})
}
}
impl Iterator for WrapRows<'_> {
type Item = RowInfo;
fn next(&mut self) -> Option<Self::Item> {
if self.output_row > self.max_output_row {
return None;
}
let buffer_row = self.input_buffer_row;
let soft_wrapped = self.soft_wrapped;
let diff_status = self.input_buffer_row.diff_status;
self.output_row += 1;
self.transforms
.seek_forward(&WrapPoint::new(self.output_row, 0), Bias::Left);
if self.transforms.item().is_some_and(|t| t.is_isomorphic()) {
self.input_buffer_row = self.input_buffer_rows.next().unwrap();
self.soft_wrapped = false;
} else {
self.soft_wrapped = true;
}
Some(if soft_wrapped {
RowInfo {
buffer_id: None,
buffer_row: None,
multibuffer_row: None,
diff_status,
expand_info: None,
}
} else {
buffer_row
})
}
}
impl Transform {
fn isomorphic(summary: TextSummary) -> Self {
#[cfg(test)]
assert!(!summary.lines.is_zero());
Self {
summary: TransformSummary {
input: summary.clone(),
output: summary,
},
display_text: None,
}
}
fn wrap(indent: u32) -> Self {
static WRAP_TEXT: LazyLock<String> = LazyLock::new(|| {
let mut wrap_text = String::new();
wrap_text.push('\n');
wrap_text.extend((0..LineWrapper::MAX_INDENT as usize).map(|_| ' '));
wrap_text
});
Self {
summary: TransformSummary {
input: TextSummary::default(),
output: TextSummary {
lines: Point::new(1, indent),
first_line_chars: 0,
last_line_chars: indent,
longest_row: 1,
longest_row_chars: indent,
},
},
display_text: Some(&WRAP_TEXT[..1 + indent as usize]),
}
}
fn is_isomorphic(&self) -> bool {
self.display_text.is_none()
}
}
impl sum_tree::Item for Transform {
type Summary = TransformSummary;
fn summary(&self, _cx: &()) -> Self::Summary {
self.summary.clone()
}
}
fn push_isomorphic(transforms: &mut Vec<Transform>, summary: TextSummary) {
if let Some(last_transform) = transforms.last_mut()
&& last_transform.is_isomorphic()
{
last_transform.summary.input += &summary;
last_transform.summary.output += &summary;
return;
}
transforms.push(Transform::isomorphic(summary));
}
trait SumTreeExt {
fn push_or_extend(&mut self, transform: Transform);
}
impl SumTreeExt for SumTree<Transform> {
fn push_or_extend(&mut self, transform: Transform) {
let mut transform = Some(transform);
self.update_last(
|last_transform| {
if last_transform.is_isomorphic() && transform.as_ref().unwrap().is_isomorphic() {
let transform = transform.take().unwrap();
last_transform.summary.input += &transform.summary.input;
last_transform.summary.output += &transform.summary.output;
}
},
&(),
);
if let Some(transform) = transform {
self.push(transform, &());
}
}
}
impl WrapPoint {
pub fn new(row: u32, column: u32) -> Self {
Self(Point::new(row, column))
}
pub fn row(self) -> u32 {
self.0.row
}
pub fn row_mut(&mut self) -> &mut u32 {
&mut self.0.row
}
pub fn column(self) -> u32 {
self.0.column
}
pub fn column_mut(&mut self) -> &mut u32 {
&mut self.0.column
}
}
impl sum_tree::Summary for TransformSummary {
type Context = ();
fn zero(_cx: &()) -> Self {
Default::default()
}
fn add_summary(&mut self, other: &Self, _: &()) {
self.input += &other.input;
self.output += &other.output;
}
}
impl<'a> sum_tree::Dimension<'a, TransformSummary> for TabPoint {
fn zero(_cx: &()) -> Self {
Default::default()
}
fn add_summary(&mut self, summary: &'a TransformSummary, _: &()) {
self.0 += summary.input.lines;
}
}
impl sum_tree::SeekTarget<'_, TransformSummary, TransformSummary> for TabPoint {
fn cmp(&self, cursor_location: &TransformSummary, _: &()) -> std::cmp::Ordering {
Ord::cmp(&self.0, &cursor_location.input.lines)
}
}
impl<'a> sum_tree::Dimension<'a, TransformSummary> for WrapPoint {
fn zero(_cx: &()) -> Self {
Default::default()
}
fn add_summary(&mut self, summary: &'a TransformSummary, _: &()) {
self.0 += summary.output.lines;
}
}
fn consolidate_wrap_edits(edits: Vec<WrapEdit>) -> Vec<WrapEdit> {
let _old_alloc_ptr = edits.as_ptr();
let mut wrap_edits = edits.into_iter();
if let Some(mut first_edit) = wrap_edits.next() {
// This code relies on reusing allocations from the Vec<_> - at the time of writing .flatten() prevents them.
#[allow(clippy::filter_map_identity)]
let mut v: Vec<_> = wrap_edits
.scan(&mut first_edit, |prev_edit, edit| {
if prev_edit.old.end >= edit.old.start {
prev_edit.old.end = edit.old.end;
prev_edit.new.end = edit.new.end;
Some(None) // Skip this edit, it's merged
} else {
let prev = std::mem::replace(*prev_edit, edit);
Some(Some(prev)) // Yield the previous edit
}
})
.filter_map(|x| x)
.collect();
v.push(first_edit.clone());
debug_assert_eq!(v.as_ptr(), _old_alloc_ptr, "Wrap edits were reallocated");
v
} else {
vec![]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
MultiBuffer,
display_map::{fold_map::FoldMap, inlay_map::InlayMap, tab_map::TabMap},
test::test_font,
};
use gpui::{LineFragment, px, test::observe};
use rand::prelude::*;
use settings::SettingsStore;
use smol::stream::StreamExt;
use std::{cmp, env, num::NonZeroU32};
use text::Rope;
use theme::LoadThemes;
#[gpui::test(iterations = 100)]
async fn test_random_wraps(cx: &mut gpui::TestAppContext, mut rng: StdRng) {
// todo this test is flaky
init_test(cx);
cx.background_executor.set_block_on_ticks(0..=50);
let operations = env::var("OPERATIONS")
.map(|i| i.parse().expect("invalid `OPERATIONS` variable"))
.unwrap_or(10);
let text_system = cx.read(|cx| cx.text_system().clone());
let mut wrap_width = if rng.random_bool(0.1) {
None
} else {
Some(px(rng.random_range(0.0..=1000.0)))
};
let tab_size = NonZeroU32::new(rng.random_range(1..=4)).unwrap();
let font = test_font();
let _font_id = text_system.resolve_font(&font);
let font_size = px(14.0);
log::info!("Tab size: {}", tab_size);
log::info!("Wrap width: {:?}", wrap_width);
let buffer = cx.update(|cx| {
if rng.random() {
MultiBuffer::build_random(&mut rng, cx)
} else {
let len = rng.random_range(0..10);
let text = util::RandomCharIter::new(&mut rng)
.take(len)
.collect::<String>();
MultiBuffer::build_simple(&text, cx)
}
});
let mut buffer_snapshot = buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx));
log::info!("Buffer text: {:?}", buffer_snapshot.text());
let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone());
log::info!("InlayMap text: {:?}", inlay_snapshot.text());
let (mut fold_map, fold_snapshot) = FoldMap::new(inlay_snapshot.clone());
log::info!("FoldMap text: {:?}", fold_snapshot.text());
let (mut tab_map, _) = TabMap::new(fold_snapshot.clone(), tab_size);
let tabs_snapshot = tab_map.set_max_expansion_column(32);
log::info!("TabMap text: {:?}", tabs_snapshot.text());
let mut line_wrapper = text_system.line_wrapper(font.clone(), font_size);
let expected_text = wrap_text(&tabs_snapshot, wrap_width, &mut line_wrapper);
let (wrap_map, _) =
cx.update(|cx| WrapMap::new(tabs_snapshot.clone(), font, font_size, wrap_width, cx));
let mut notifications = observe(&wrap_map, cx);
if wrap_map.read_with(cx, |map, _| map.is_rewrapping()) {
notifications.next().await.unwrap();
}
let (initial_snapshot, _) = wrap_map.update(cx, |map, cx| {
assert!(!map.is_rewrapping());
map.sync(tabs_snapshot.clone(), Vec::new(), cx)
});
let actual_text = initial_snapshot.text();
assert_eq!(
actual_text,
expected_text,
"unwrapped text is: {:?}",
tabs_snapshot.text()
);
log::info!("Wrapped text: {:?}", actual_text);
let mut next_inlay_id = 0;
let mut edits = Vec::new();
for _i in 0..operations {
log::info!("{} ==============================================", _i);
let mut buffer_edits = Vec::new();
match rng.random_range(0..=100) {
0..=19 => {
wrap_width = if rng.random_bool(0.2) {
None
} else {
Some(px(rng.random_range(0.0..=1000.0)))
};
log::info!("Setting wrap width to {:?}", wrap_width);
wrap_map.update(cx, |map, cx| map.set_wrap_width(wrap_width, cx));
}
20..=39 => {
for (fold_snapshot, fold_edits) in fold_map.randomly_mutate(&mut rng) {
let (tabs_snapshot, tab_edits) =
tab_map.sync(fold_snapshot, fold_edits, tab_size);
let (mut snapshot, wrap_edits) =
wrap_map.update(cx, |map, cx| map.sync(tabs_snapshot, tab_edits, cx));
snapshot.check_invariants();
snapshot.verify_chunks(&mut rng);
edits.push((snapshot, wrap_edits));
}
}
40..=59 => {
let (inlay_snapshot, inlay_edits) =
inlay_map.randomly_mutate(&mut next_inlay_id, &mut rng);
let (fold_snapshot, fold_edits) = fold_map.read(inlay_snapshot, inlay_edits);
let (tabs_snapshot, tab_edits) =
tab_map.sync(fold_snapshot, fold_edits, tab_size);
let (mut snapshot, wrap_edits) =
wrap_map.update(cx, |map, cx| map.sync(tabs_snapshot, tab_edits, cx));
snapshot.check_invariants();
snapshot.verify_chunks(&mut rng);
edits.push((snapshot, wrap_edits));
}
_ => {
buffer.update(cx, |buffer, cx| {
let subscription = buffer.subscribe();
let edit_count = rng.random_range(1..=5);
buffer.randomly_mutate(&mut rng, edit_count, cx);
buffer_snapshot = buffer.snapshot(cx);
buffer_edits.extend(subscription.consume());
});
}
}
log::info!("Buffer text: {:?}", buffer_snapshot.text());
let (inlay_snapshot, inlay_edits) =
inlay_map.sync(buffer_snapshot.clone(), buffer_edits);
log::info!("InlayMap text: {:?}", inlay_snapshot.text());
let (fold_snapshot, fold_edits) = fold_map.read(inlay_snapshot, inlay_edits);
log::info!("FoldMap text: {:?}", fold_snapshot.text());
let (tabs_snapshot, tab_edits) = tab_map.sync(fold_snapshot, fold_edits, tab_size);
log::info!("TabMap text: {:?}", tabs_snapshot.text());
let expected_text = wrap_text(&tabs_snapshot, wrap_width, &mut line_wrapper);
let (mut snapshot, wrap_edits) =
wrap_map.update(cx, |map, cx| map.sync(tabs_snapshot.clone(), tab_edits, cx));
snapshot.check_invariants();
snapshot.verify_chunks(&mut rng);
edits.push((snapshot, wrap_edits));
if wrap_map.read_with(cx, |map, _| map.is_rewrapping()) && rng.random_bool(0.4) {
log::info!("Waiting for wrapping to finish");
while wrap_map.read_with(cx, |map, _| map.is_rewrapping()) {
notifications.next().await.unwrap();
}
wrap_map.read_with(cx, |map, _| assert!(map.pending_edits.is_empty()));
}
if !wrap_map.read_with(cx, |map, _| map.is_rewrapping()) {
let (mut wrapped_snapshot, wrap_edits) = wrap_map.update(cx, |map, cx| {
map.sync(tabs_snapshot.clone(), Vec::new(), cx)
});
let actual_text = wrapped_snapshot.text();
let actual_longest_row = wrapped_snapshot.longest_row();
log::info!("Wrapping finished: {:?}", actual_text);
wrapped_snapshot.check_invariants();
wrapped_snapshot.verify_chunks(&mut rng);
edits.push((wrapped_snapshot.clone(), wrap_edits));
assert_eq!(
actual_text,
expected_text,
"unwrapped text is: {:?}",
tabs_snapshot.text()
);
let mut summary = TextSummary::default();
for (ix, item) in wrapped_snapshot
.transforms
.items(&())
.into_iter()
.enumerate()
{
summary += &item.summary.output;
log::info!("{} summary: {:?}", ix, item.summary.output,);
}
if tab_size.get() == 1
|| !wrapped_snapshot
.tab_snapshot
.fold_snapshot
.text()
.contains('\t')
{
let mut expected_longest_rows = Vec::new();
let mut longest_line_len = -1;
for (row, line) in expected_text.split('\n').enumerate() {
let line_char_count = line.chars().count() as isize;
if line_char_count > longest_line_len {
expected_longest_rows.clear();
longest_line_len = line_char_count;
}
if line_char_count >= longest_line_len {
expected_longest_rows.push(row as u32);
}
}
assert!(
expected_longest_rows.contains(&actual_longest_row),
"incorrect longest row {}. expected {:?} with length {}",
actual_longest_row,
expected_longest_rows,
longest_line_len,
)
}
}
}
let mut initial_text = Rope::from(initial_snapshot.text().as_str());
for (snapshot, patch) in edits {
let snapshot_text = Rope::from(snapshot.text().as_str());
for edit in &patch {
let old_start = initial_text.point_to_offset(Point::new(edit.new.start, 0));
let old_end = initial_text.point_to_offset(cmp::min(
Point::new(edit.new.start + edit.old.len() as u32, 0),
initial_text.max_point(),
));
let new_start = snapshot_text.point_to_offset(Point::new(edit.new.start, 0));
let new_end = snapshot_text.point_to_offset(cmp::min(
Point::new(edit.new.end, 0),
snapshot_text.max_point(),
));
let new_text = snapshot_text
.chunks_in_range(new_start..new_end)
.collect::<String>();
initial_text.replace(old_start..old_end, &new_text);
}
assert_eq!(initial_text.to_string(), snapshot_text.to_string());
}
if wrap_map.read_with(cx, |map, _| map.is_rewrapping()) {
log::info!("Waiting for wrapping to finish");
while wrap_map.read_with(cx, |map, _| map.is_rewrapping()) {
notifications.next().await.unwrap();
}
}
wrap_map.read_with(cx, |map, _| assert!(map.pending_edits.is_empty()));
}
fn init_test(cx: &mut gpui::TestAppContext) {
cx.update(|cx| {
let settings = SettingsStore::test(cx);
cx.set_global(settings);
theme::init(LoadThemes::JustBase, cx);
});
}
fn wrap_text(
tab_snapshot: &TabSnapshot,
wrap_width: Option<Pixels>,
line_wrapper: &mut LineWrapper,
) -> String {
if let Some(wrap_width) = wrap_width {
let mut wrapped_text = String::new();
for (row, line) in tab_snapshot.text().split('\n').enumerate() {
if row > 0 {
wrapped_text.push('\n');
}
let mut prev_ix = 0;
for boundary in line_wrapper.wrap_line(&[LineFragment::text(line)], wrap_width) {
wrapped_text.push_str(&line[prev_ix..boundary.ix]);
wrapped_text.push('\n');
wrapped_text.push_str(&" ".repeat(boundary.next_indent as usize));
prev_ix = boundary.ix;
}
wrapped_text.push_str(&line[prev_ix..]);
}
wrapped_text
} else {
tab_snapshot.text()
}
}
impl WrapSnapshot {
fn verify_chunks(&mut self, rng: &mut impl Rng) {
for _ in 0..5 {
let mut end_row = rng.random_range(0..=self.max_point().row());
let start_row = rng.random_range(0..=end_row);
end_row += 1;
let mut expected_text = self.text_chunks(start_row).collect::<String>();
if expected_text.ends_with('\n') {
expected_text.push('\n');
}
let mut expected_text = expected_text
.lines()
.take((end_row - start_row) as usize)
.collect::<Vec<_>>()
.join("\n");
if end_row <= self.max_point().row() {
expected_text.push('\n');
}
let actual_text = self
.chunks(start_row..end_row, true, Highlights::default())
.map(|c| c.text)
.collect::<String>();
assert_eq!(
expected_text,
actual_text,
"chunks != highlighted_chunks for rows {:?}",
start_row..end_row
);
}
}
}
}