Account for the impact of edits on tab expansion

Antonio Scandurra and Nathan Sobo created

Tab characters are expanded differently based on the column on which
they appear, which edits can affect. Thus, `TabMap::sync` will now
expand edits to the first tab that appears on the line in which the edit
occurred.

Co-Authored-By: Nathan Sobo <nathan@zed.dev>

Change summary

zed/src/editor/display_map/tab_map.rs  | 90 +++++++++++++++++++++------
zed/src/editor/display_map/wrap_map.rs | 34 +++++----
2 files changed, 89 insertions(+), 35 deletions(-)

Detailed changes

zed/src/editor/display_map/tab_map.rs 🔗

@@ -5,7 +5,7 @@ use super::fold_map::{
     OutputOffset as InputOffset, OutputPoint as InputPoint, Snapshot as InputSnapshot,
 };
 use crate::{editor::rope, settings::StyleId, util::Bias};
-use std::{mem, ops::Range};
+use std::{cmp, mem, ops::Range};
 
 pub struct TabMap(Mutex<Snapshot>);
 
@@ -18,7 +18,7 @@ impl TabMap {
     pub fn sync(
         &self,
         snapshot: InputSnapshot,
-        input_edits: Vec<InputEdit>,
+        mut input_edits: Vec<InputEdit>,
     ) -> (Snapshot, Vec<Edit>) {
         let mut old_snapshot = self.0.lock();
         let new_snapshot = Snapshot {
@@ -27,6 +27,39 @@ impl TabMap {
         };
 
         let mut output_edits = Vec::with_capacity(input_edits.len());
+        for input_edit in &mut input_edits {
+            let mut delta = 0;
+            for chunk in old_snapshot.input.chunks_at(input_edit.old_bytes.end) {
+                let patterns: &[_] = &['\t', '\n'];
+                if let Some(ix) = chunk.find(patterns) {
+                    if &chunk[ix..ix + 1] == "\t" {
+                        input_edit.old_bytes.end.0 += delta + ix + old_snapshot.tab_size;
+                        input_edit.new_bytes.end.0 += delta + ix + new_snapshot.tab_size;
+                    }
+
+                    break;
+                }
+
+                delta += chunk.len();
+            }
+            input_edit.old_bytes.end = cmp::min(input_edit.old_bytes.end, old_snapshot.input.len());
+            input_edit.new_bytes.end = cmp::min(input_edit.new_bytes.end, new_snapshot.input.len());
+        }
+
+        let mut ix = 1;
+        while ix < input_edits.len() {
+            let (prev_edits, next_edits) = input_edits.split_at_mut(ix);
+            let prev_edit = prev_edits.last_mut().unwrap();
+            let edit = &next_edits[0];
+            if prev_edit.old_bytes.end >= edit.old_bytes.start {
+                prev_edit.old_bytes.end = edit.old_bytes.end;
+                prev_edit.new_bytes.end = edit.new_bytes.end;
+                input_edits.remove(ix);
+            } else {
+                ix += 1;
+            }
+        }
+
         for input_edit in input_edits {
             let old_start = input_edit.old_bytes.start.to_point(&old_snapshot.input);
             let old_end = input_edit.old_bytes.end.to_point(&old_snapshot.input);
@@ -53,28 +86,45 @@ pub struct Snapshot {
 
 impl Snapshot {
     pub fn text_summary(&self) -> TextSummary {
-        // TODO: expand tabs on first and last line, ignoring the longest row.
-        let summary = self.input.text_summary();
-        TextSummary {
-            lines: summary.lines,
-            first_line_chars: summary.first_line_chars,
-            last_line_chars: summary.last_line_chars,
-            longest_row: summary.longest_row,
-            longest_row_chars: summary.longest_row_chars,
-        }
+        self.text_summary_for_range(OutputPoint::zero()..self.max_point())
     }
 
     pub fn text_summary_for_range(&self, range: Range<OutputPoint>) -> TextSummary {
-        // TODO: expand tabs on first and last line, ignoring the longest row.
-        let start = self.to_input_point(range.start, Bias::Left).0;
-        let end = self.to_input_point(range.end, Bias::Right).0;
-        let summary = self.input.text_summary_for_range(start..end);
+        let input_start = self.to_input_point(range.start, Bias::Left).0;
+        let input_end = self.to_input_point(range.end, Bias::Right).0;
+        let input_summary = self.input.text_summary_for_range(input_start..input_end);
+
+        let mut first_line_chars = 0;
+        let mut first_line_bytes = 0;
+        for c in self.chunks_at(range.start).flat_map(|chunk| chunk.chars()) {
+            if c == '\n'
+                || (range.start.row() == range.end.row() && first_line_bytes == range.end.column())
+            {
+                break;
+            }
+            first_line_chars += 1;
+            first_line_bytes += c.len_utf8() as u32;
+        }
+
+        let mut last_line_chars = 0;
+        let mut last_line_bytes = 0;
+        for c in self
+            .chunks_at(OutputPoint::new(range.end.row(), 0).max(range.start))
+            .flat_map(|chunk| chunk.chars())
+        {
+            if last_line_bytes == range.end.column() {
+                break;
+            }
+            last_line_chars += 1;
+            last_line_bytes += c.len_utf8() as u32;
+        }
+
         TextSummary {
-            lines: summary.lines,
-            first_line_chars: summary.first_line_chars,
-            last_line_chars: summary.last_line_chars,
-            longest_row: summary.longest_row,
-            longest_row_chars: summary.longest_row_chars,
+            lines: range.end.0 - range.start.0,
+            first_line_chars,
+            last_line_chars,
+            longest_row: input_summary.longest_row,
+            longest_row_chars: input_summary.longest_row_chars,
         }
     }
 

zed/src/editor/display_map/wrap_map.rs 🔗

@@ -755,28 +755,30 @@ mod tests {
         for seed in seed_range {
             dbg!(seed);
             let mut rng = StdRng::seed_from_u64(seed);
-
-            let buffer = cx.add_model(|cx| {
-                let len = rng.gen_range(0..10);
-                let text = RandomCharIter::new(&mut rng).take(len).collect::<String>();
-                log::info!("Initial buffer text: {:?}", text);
-                Buffer::new(0, text, cx)
-            });
-            let (fold_map, folds_snapshot) = FoldMap::new(buffer.clone(), cx.as_ref());
-            let (tab_map, tabs_snapshot) =
-                TabMap::new(folds_snapshot.clone(), rng.gen_range(1..=4));
             let font_cache = cx.font_cache().clone();
             let font_system = cx.platform().fonts();
             let wrap_width = rng.gen_range(100.0..=1000.0);
             let settings = Settings {
-                tab_size: 4,
+                tab_size: rng.gen_range(1..=4),
                 buffer_font_family: font_cache.load_family(&["Helvetica"]).unwrap(),
                 buffer_font_size: 14.0,
                 ..Settings::new(&font_cache).unwrap()
             };
+            log::info!("Tab size: {}", settings.tab_size);
+            log::info!("Wrap width: {}", wrap_width);
+
             let font_id = font_cache
                 .select_font(settings.buffer_font_family, &Default::default())
                 .unwrap();
+
+            let buffer = cx.add_model(|cx| {
+                let len = rng.gen_range(0..10);
+                let text = RandomCharIter::new(&mut rng).take(len).collect::<String>();
+                log::info!("Initial buffer text: {:?} (len: {})", text, text.len());
+                Buffer::new(0, text, cx)
+            });
+            let (fold_map, folds_snapshot) = FoldMap::new(buffer.clone(), cx.as_ref());
+            let (tab_map, tabs_snapshot) = TabMap::new(folds_snapshot.clone(), settings.tab_size);
             let mut wrapper = BackgroundWrapper::new(
                 Snapshot::new(tabs_snapshot.clone()),
                 settings.clone(),
@@ -818,10 +820,8 @@ mod tests {
                     wrap_text(&unwrapped_text, wrap_width, font_id, font_system.as_ref());
                 wrapper.sync(snapshot, edits);
                 wrapper.snapshot.check_invariants();
-                let actual_text = wrapper
-                    .snapshot
-                    .chunks_at(OutputPoint::zero())
-                    .collect::<String>();
+
+                let actual_text = wrapper.snapshot.text();
                 assert_eq!(
                     actual_text, expected_text,
                     "unwrapped text is: {:?}",
@@ -857,6 +857,10 @@ mod tests {
     }
 
     impl Snapshot {
+        fn text(&self) -> String {
+            self.chunks_at(OutputPoint::zero()).collect()
+        }
+
         fn check_invariants(&self) {
             assert_eq!(
                 InputPoint::from(self.transforms.summary().input.lines),