editor: Render boundary whitespace (#11954)

Nicholas Cioli and Nicholas Cioli created

![image](https://github.com/zed-industries/zed/assets/1240491/3dd06e45-ae8e-49d5-984d-3d8bdf98d983)

Added support for only rendering whitespace that is on a
boundary, the logic of which is explained below:

- Any tab character
- Whitespace at the start and end of a line
- Whitespace that is directly adjacent to another whitespace


Release Notes:

- Added `boundary` whitespace rendering option
([#4290](https://github.com/zed-industries/zed/issues/4290)).




---------

Co-authored-by: Nicholas Cioli <nicholascioli@users.noreply.github.com>

Change summary

crates/editor/src/element.rs             | 114 +++++++++++++++++++++----
crates/language/src/language_settings.rs |   7 +
2 files changed, 102 insertions(+), 19 deletions(-)

Detailed changes

crates/editor/src/element.rs 🔗

@@ -4071,6 +4071,7 @@ impl LineWithInvisibles {
                                 if non_whitespace_added || !inside_wrapped_string {
                                     invisibles.push(Invisible::Tab {
                                         line_start_offset: line.len(),
+                                        line_end_offset: line.len() + line_chunk.len(),
                                     });
                                 }
                             } else {
@@ -4186,16 +4187,15 @@ impl LineWithInvisibles {
         whitespace_setting: ShowWhitespaceSetting,
         cx: &mut WindowContext,
     ) {
-        let allowed_invisibles_regions = match whitespace_setting {
-            ShowWhitespaceSetting::None => return,
-            ShowWhitespaceSetting::Selection => Some(selection_ranges),
-            ShowWhitespaceSetting::All => None,
-        };
-
-        for invisible in &self.invisibles {
-            let (&token_offset, invisible_symbol) = match invisible {
-                Invisible::Tab { line_start_offset } => (line_start_offset, &layout.tab_invisible),
-                Invisible::Whitespace { line_offset } => (line_offset, &layout.space_invisible),
+        let extract_whitespace_info = |invisible: &Invisible| {
+            let (token_offset, token_end_offset, invisible_symbol) = match invisible {
+                Invisible::Tab {
+                    line_start_offset,
+                    line_end_offset,
+                } => (*line_start_offset, *line_end_offset, &layout.tab_invisible),
+                Invisible::Whitespace { line_offset } => {
+                    (*line_offset, line_offset + 1, &layout.space_invisible)
+                }
             };
 
             let x_offset = self.x_for_index(token_offset);
@@ -4207,17 +4207,73 @@ impl LineWithInvisibles {
                     line_y,
                 );
 
-            if let Some(allowed_regions) = allowed_invisibles_regions {
-                let invisible_point = DisplayPoint::new(row, token_offset as u32);
-                if !allowed_regions
+            (
+                [token_offset, token_end_offset],
+                Box::new(move |cx: &mut WindowContext| {
+                    invisible_symbol.paint(origin, line_height, cx).log_err();
+                }),
+            )
+        };
+
+        let invisible_iter = self.invisibles.iter().map(extract_whitespace_info);
+        match whitespace_setting {
+            ShowWhitespaceSetting::None => return,
+            ShowWhitespaceSetting::All => invisible_iter.for_each(|(_, paint)| paint(cx)),
+            ShowWhitespaceSetting::Selection => invisible_iter.for_each(|([start, _], paint)| {
+                let invisible_point = DisplayPoint::new(row, start as u32);
+                if !selection_ranges
                     .iter()
                     .any(|region| region.start <= invisible_point && invisible_point < region.end)
                 {
-                    continue;
+                    return;
+                }
+
+                paint(cx);
+            }),
+
+            // For a whitespace to be on a boundary, any of the following conditions need to be met:
+            // - It is a tab
+            // - It is adjacent to an edge (start or end)
+            // - It is adjacent to a whitespace (left or right)
+            ShowWhitespaceSetting::Boundary => {
+                // We'll need to keep track of the last invisible we've seen and then check if we are adjacent to it for some of
+                // the above cases.
+                // Note: We zip in the original `invisibles` to check for tab equality
+                let mut last_seen: Option<(bool, usize, Box<dyn Fn(&mut WindowContext)>)> = None;
+                for (([start, end], paint), invisible) in
+                    invisible_iter.zip_eq(self.invisibles.iter())
+                {
+                    let should_render = match (&last_seen, invisible) {
+                        (_, Invisible::Tab { .. }) => true,
+                        (Some((_, last_end, _)), _) => *last_end == start,
+                        _ => false,
+                    };
+
+                    if should_render || start == 0 || end == self.len {
+                        paint(cx);
+
+                        // Since we are scanning from the left, we will skip over the first available whitespace that is part
+                        // of a boundary between non-whitespace segments, so we correct by manually redrawing it if needed.
+                        if let Some((should_render_last, last_end, paint_last)) = last_seen {
+                            // Note that we need to make sure that the last one is actually adjacent
+                            if !should_render_last && last_end == start {
+                                paint_last(cx);
+                            }
+                        }
+                    }
+
+                    // Manually render anything within a selection
+                    let invisible_point = DisplayPoint::new(row, start as u32);
+                    if selection_ranges.iter().any(|region| {
+                        region.start <= invisible_point && invisible_point < region.end
+                    }) {
+                        paint(cx);
+                    }
+
+                    last_seen = Some((should_render, end, paint));
                 }
             }
-            invisible_symbol.paint(origin, line_height, cx).log_err();
-        }
+        };
     }
 
     pub fn x_for_index(&self, index: usize) -> Pixels {
@@ -4307,8 +4363,18 @@ impl LineWithInvisibles {
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 enum Invisible {
-    Tab { line_start_offset: usize },
-    Whitespace { line_offset: usize },
+    /// A tab character
+    ///
+    /// A tab character is internally represented by spaces (configured by the user's tab width)
+    /// aligned to the nearest column, so it's necessary to store the start and end offset for
+    /// adjacency checks.
+    Tab {
+        line_start_offset: usize,
+        line_end_offset: usize,
+    },
+    Whitespace {
+        line_offset: usize,
+    },
 }
 
 impl EditorElement {
@@ -5853,15 +5919,18 @@ mod tests {
         let expected_invisibles = vec![
             Invisible::Tab {
                 line_start_offset: 0,
+                line_end_offset: TAB_SIZE as usize,
             },
             Invisible::Whitespace {
                 line_offset: TAB_SIZE as usize,
             },
             Invisible::Tab {
                 line_start_offset: TAB_SIZE as usize + 1,
+                line_end_offset: TAB_SIZE as usize * 2,
             },
             Invisible::Tab {
                 line_start_offset: TAB_SIZE as usize * 2 + 1,
+                line_end_offset: TAB_SIZE as usize * 3,
             },
             Invisible::Whitespace {
                 line_offset: TAB_SIZE as usize * 3 + 1,
@@ -5915,10 +5984,11 @@ mod tests {
     #[gpui::test]
     fn test_wrapped_invisibles_drawing(cx: &mut TestAppContext) {
         let tab_size = 4;
-        let input_text = "a\tbcd   ".repeat(9);
+        let input_text = "a\tbcd     ".repeat(9);
         let repeated_invisibles = [
             Invisible::Tab {
                 line_start_offset: 1,
+                line_end_offset: tab_size as usize,
             },
             Invisible::Whitespace {
                 line_offset: tab_size as usize + 3,
@@ -5929,6 +5999,12 @@ mod tests {
             Invisible::Whitespace {
                 line_offset: tab_size as usize + 5,
             },
+            Invisible::Whitespace {
+                line_offset: tab_size as usize + 6,
+            },
+            Invisible::Whitespace {
+                line_offset: tab_size as usize + 7,
+            },
         ];
         let expected_invisibles = std::iter::once(repeated_invisibles)
             .cycle()

crates/language/src/language_settings.rs 🔗

@@ -391,6 +391,13 @@ pub enum ShowWhitespaceSetting {
     None,
     /// Draw all invisible symbols.
     All,
+    /// Draw whitespace only at boundaries.
+    ///
+    /// For a whitespace to be on a boundary, any of the following conditions need to be met:
+    /// - It is a tab
+    /// - It is adjacent to an edge (start or end)
+    /// - It is adjacent to a whitespace (left or right)
+    Boundary,
 }
 
 /// Controls which formatter should be used when formatting code.