text rendering: support strikethroughs (#7363)

Kieran Gill created

<img width="1269" alt="image"
src="https://github.com/zed-industries/zed/assets/18583882/d4c93033-b2ac-4ae0-8e12-457f256ee869">

Release Notes:

- Added support for styling text with strikethrough.

Related: 
- https://github.com/zed-industries/zed/issues/5364
- https://github.com/zed-industries/zed/pull/7345

Change summary

crates/assistant/src/assistant_panel.rs           |  2 
crates/collab_ui/src/chat_panel/message_editor.rs |  1 
crates/collab_ui/src/collab_panel.rs              |  1 
crates/editor/src/editor.rs                       |  2 
crates/editor/src/element.rs                      |  8 ++
crates/gpui/src/style.rs                          | 28 ++++++++
crates/gpui/src/text_system.rs                    |  9 ++
crates/gpui/src/text_system/line.rs               | 53 ++++++++++++++++
crates/gpui/src/text_system/line_wrapper.rs       |  2 
crates/gpui/src/window/element_cx.rs              | 36 ++++++++++
crates/outline/src/outline.rs                     |  1 
crates/search/src/buffer_search.rs                |  1 
crates/search/src/project_search.rs               |  1 
crates/terminal_view/src/terminal_element.rs      |  4 +
14 files changed, 145 insertions(+), 4 deletions(-)

Detailed changes

crates/assistant/src/assistant_panel.rs 🔗

@@ -962,6 +962,7 @@ impl AssistantPanel {
             line_height: relative(1.3).into(),
             background_color: None,
             underline: None,
+            strikethrough: None,
             white_space: WhiteSpace::Normal,
         };
         EditorElement::new(
@@ -3166,6 +3167,7 @@ impl InlineAssistant {
             line_height: relative(1.3).into(),
             background_color: None,
             underline: None,
+            strikethrough: None,
             white_space: WhiteSpace::Normal,
         };
         EditorElement::new(

crates/collab_ui/src/collab_panel.rs 🔗

@@ -2068,6 +2068,7 @@ impl CollabPanel {
             line_height: relative(1.3).into(),
             background_color: None,
             underline: None,
+            strikethrough: None,
             white_space: WhiteSpace::Normal,
         };
 

crates/editor/src/editor.rs 🔗

@@ -9495,6 +9495,7 @@ impl Render for Editor {
                 line_height: relative(settings.buffer_line_height.value()),
                 background_color: None,
                 underline: None,
+                strikethrough: None,
                 white_space: WhiteSpace::Normal,
             },
 
@@ -9508,6 +9509,7 @@ impl Render for Editor {
                 line_height: relative(settings.buffer_line_height.value()),
                 background_color: None,
                 underline: None,
+                strikethrough: None,
                 white_space: WhiteSpace::Normal,
             },
         };

crates/editor/src/element.rs 🔗

@@ -1073,6 +1073,7 @@ impl EditorElement {
                                                         font: self.style.text.font(),
                                                         color: self.style.background,
                                                         background_color: None,
+                                                        strikethrough: None,
                                                         underline: None,
                                                     }],
                                                 )
@@ -1713,6 +1714,7 @@ impl EditorElement {
                     color: Hsla::default(),
                     background_color: None,
                     underline: None,
+                    strikethrough: None,
                 }],
             )
             .unwrap();
@@ -1849,6 +1851,7 @@ impl EditorElement {
                         color,
                         background_color: None,
                         underline: None,
+                        strikethrough: None,
                     };
                     let shaped_line = cx
                         .text_system()
@@ -1906,6 +1909,7 @@ impl EditorElement {
                         color: placeholder_color,
                         background_color: None,
                         underline: Default::default(),
+                        strikethrough: None,
                     };
                     cx.text_system()
                         .shape_line(line.to_string().into(), font_size, &[run])
@@ -2321,6 +2325,7 @@ impl EditorElement {
                         color: cx.theme().colors().editor_invisible,
                         background_color: None,
                         underline: None,
+                        strikethrough: None,
                     }],
                 )
                 .unwrap();
@@ -2335,6 +2340,7 @@ impl EditorElement {
                         color: cx.theme().colors().editor_invisible,
                         background_color: None,
                         underline: None,
+                        strikethrough: None,
                     }],
                 )
                 .unwrap();
@@ -2868,6 +2874,7 @@ impl LineWithInvisibles {
                         color: text_style.color,
                         background_color: text_style.background_color,
                         underline: text_style.underline,
+                        strikethrough: text_style.strikethrough,
                     });
 
                     if editor_mode == EditorMode::Full {
@@ -3281,6 +3288,7 @@ fn layout_line(
             color: Hsla::default(),
             background_color: None,
             underline: None,
+            strikethrough: None,
         }],
     )
 }

crates/gpui/src/style.rs 🔗

@@ -197,6 +197,9 @@ pub struct TextStyle {
     /// The underline style of the text
     pub underline: Option<UnderlineStyle>,
 
+    /// The strikethrough style of the text
+    pub strikethrough: Option<StrikethroughStyle>,
+
     /// How to handle whitespace in the text
     pub white_space: WhiteSpace,
 }
@@ -214,6 +217,7 @@ impl Default for TextStyle {
             font_style: FontStyle::default(),
             background_color: None,
             underline: None,
+            strikethrough: None,
             white_space: WhiteSpace::Normal,
         }
     }
@@ -246,6 +250,10 @@ impl TextStyle {
             self.underline = Some(underline);
         }
 
+        if let Some(strikethrough) = style.strikethrough {
+            self.strikethrough = Some(strikethrough);
+        }
+
         self
     }
 
@@ -277,6 +285,7 @@ impl TextStyle {
             color: self.color,
             background_color: self.background_color,
             underline: self.underline,
+            strikethrough: self.strikethrough,
         }
     }
 }
@@ -300,6 +309,9 @@ pub struct HighlightStyle {
     /// The underline style of the text
     pub underline: Option<UnderlineStyle>,
 
+    /// The underline style of the text
+    pub strikethrough: Option<StrikethroughStyle>,
+
     /// Similar to the CSS `opacity` property, this will cause the text to be less vibrant.
     pub fade_out: Option<f32>,
 }
@@ -553,6 +565,17 @@ pub struct UnderlineStyle {
     pub wavy: bool,
 }
 
+/// The properties that can be applied to a strikethrough.
+#[derive(Refineable, Copy, Clone, Default, Debug, PartialEq, Eq)]
+#[refineable(Debug)]
+pub struct StrikethroughStyle {
+    /// The thickness of the strikethrough.
+    pub thickness: Pixels,
+
+    /// The color of the strikethrough.
+    pub color: Option<Hsla>,
+}
+
 /// The kinds of fill that can be applied to a shape.
 #[derive(Clone, Debug)]
 pub enum Fill {
@@ -601,6 +624,7 @@ impl From<&TextStyle> for HighlightStyle {
             font_style: Some(other.font_style),
             background_color: other.background_color,
             underline: other.underline,
+            strikethrough: other.strikethrough,
             fade_out: None,
         }
     }
@@ -636,6 +660,10 @@ impl HighlightStyle {
             self.underline = other.underline;
         }
 
+        if other.strikethrough.is_some() {
+            self.strikethrough = other.strikethrough;
+        }
+
         match (other.fade_out, self.fade_out) {
             (Some(source_fade), None) => self.fade_out = Some(source_fade),
             (Some(source_fade), Some(dest_fade)) => {

crates/gpui/src/text_system.rs 🔗

@@ -10,7 +10,7 @@ pub use line_wrapper::*;
 
 use crate::{
     px, Bounds, DevicePixels, EntityId, Hsla, Pixels, PlatformTextSystem, Point, Result,
-    SharedString, Size, UnderlineStyle,
+    SharedString, Size, StrikethroughStyle, UnderlineStyle,
 };
 use anyhow::anyhow;
 use collections::{BTreeSet, FxHashMap, FxHashSet};
@@ -317,6 +317,7 @@ impl WindowTextSystem {
             if let Some(last_run) = decoration_runs.last_mut() {
                 if last_run.color == run.color
                     && last_run.underline == run.underline
+                    && last_run.strikethrough == run.strikethrough
                     && last_run.background_color == run.background_color
                 {
                     last_run.len += run.len as u32;
@@ -328,6 +329,7 @@ impl WindowTextSystem {
                 color: run.color,
                 background_color: run.background_color,
                 underline: run.underline,
+                strikethrough: run.strikethrough,
             });
         }
 
@@ -382,6 +384,7 @@ impl WindowTextSystem {
                 if decoration_runs.last().map_or(false, |last_run| {
                     last_run.color == run.color
                         && last_run.underline == run.underline
+                        && last_run.strikethrough == run.strikethrough
                         && last_run.background_color == run.background_color
                 }) {
                     decoration_runs.last_mut().unwrap().len += run_len_within_line as u32;
@@ -391,6 +394,7 @@ impl WindowTextSystem {
                         color: run.color,
                         background_color: run.background_color,
                         underline: run.underline,
+                        strikethrough: run.strikethrough,
                     });
                 }
 
@@ -406,6 +410,7 @@ impl WindowTextSystem {
             let layout = self
                 .line_layout_cache
                 .layout_wrapped_line(&line_text, font_size, &font_runs, wrap_width);
+
             lines.push(WrappedLine {
                 layout,
                 decoration_runs,
@@ -599,6 +604,8 @@ pub struct TextRun {
     pub background_color: Option<Hsla>,
     /// The underline style (if any)
     pub underline: Option<UnderlineStyle>,
+    /// The strikethrough style (if any)
+    pub strikethrough: Option<StrikethroughStyle>,
 }
 
 /// An identifier for a specific glyph, as returned by [`TextSystem::layout_line`].

crates/gpui/src/text_system/line.rs 🔗

@@ -1,6 +1,6 @@
 use crate::{
     black, fill, point, px, size, Bounds, ElementContext, Hsla, LineLayout, Pixels, Point, Result,
-    SharedString, UnderlineStyle, WrapBoundary, WrappedLineLayout,
+    SharedString, StrikethroughStyle, UnderlineStyle, WrapBoundary, WrappedLineLayout,
 };
 use derive_more::{Deref, DerefMut};
 use smallvec::SmallVec;
@@ -20,6 +20,9 @@ pub struct DecorationRun {
 
     /// The underline style for this run
     pub underline: Option<UnderlineStyle>,
+
+    /// The strikethrough style for this run
+    pub strikethrough: Option<StrikethroughStyle>,
 }
 
 /// A line of text that has been shaped and decorated.
@@ -113,6 +116,7 @@ fn paint_line(
     let mut run_end = 0;
     let mut color = black();
     let mut current_underline: Option<(Point<Pixels>, UnderlineStyle)> = None;
+    let mut current_strikethrough: Option<(Point<Pixels>, StrikethroughStyle)> = None;
     let mut current_background: Option<(Point<Pixels>, Hsla)> = None;
     let text_system = cx.text_system().clone();
     let mut glyph_origin = origin;
@@ -145,6 +149,17 @@ fn paint_line(
                     underline_origin.x = origin.x;
                     underline_origin.y += line_height;
                 }
+                if let Some((strikethrough_origin, strikethrough_style)) =
+                    current_strikethrough.as_mut()
+                {
+                    cx.paint_strikethrough(
+                        *strikethrough_origin,
+                        glyph_origin.x - strikethrough_origin.x,
+                        strikethrough_style,
+                    );
+                    strikethrough_origin.x = origin.x;
+                    strikethrough_origin.y += line_height;
+                }
 
                 glyph_origin.x = origin.x;
                 glyph_origin.y += line_height;
@@ -153,6 +168,7 @@ fn paint_line(
 
             let mut finished_background: Option<(Point<Pixels>, Hsla)> = None;
             let mut finished_underline: Option<(Point<Pixels>, UnderlineStyle)> = None;
+            let mut finished_strikethrough: Option<(Point<Pixels>, StrikethroughStyle)> = None;
             if glyph.index >= run_end {
                 if let Some(style_run) = decoration_runs.next() {
                     if let Some((_, background_color)) = &mut current_background {
@@ -183,6 +199,24 @@ fn paint_line(
                             },
                         ));
                     }
+                    if let Some((_, strikethrough_style)) = &mut current_strikethrough {
+                        if style_run.strikethrough.as_ref() != Some(strikethrough_style) {
+                            finished_strikethrough = current_strikethrough.take();
+                        }
+                    }
+                    if let Some(run_strikethrough) = style_run.strikethrough.as_ref() {
+                        current_strikethrough.get_or_insert((
+                            point(
+                                glyph_origin.x,
+                                glyph_origin.y
+                                    + (((layout.ascent * 0.5) + baseline_offset.y) * 0.5),
+                            ),
+                            StrikethroughStyle {
+                                color: Some(run_strikethrough.color.unwrap_or(style_run.color)),
+                                thickness: run_strikethrough.thickness,
+                            },
+                        ));
+                    }
 
                     run_end += style_run.len as usize;
                     color = style_run.color;
@@ -190,6 +224,7 @@ fn paint_line(
                     run_end = layout.len;
                     finished_background = current_background.take();
                     finished_underline = current_underline.take();
+                    finished_strikethrough = current_strikethrough.take();
                 }
             }
 
@@ -211,6 +246,14 @@ fn paint_line(
                 );
             }
 
+            if let Some((strikethrough_origin, strikethrough_style)) = finished_strikethrough {
+                cx.paint_strikethrough(
+                    strikethrough_origin,
+                    glyph_origin.x - strikethrough_origin.x,
+                    &strikethrough_style,
+                );
+            }
+
             let max_glyph_bounds = Bounds {
                 origin: glyph_origin,
                 size: max_glyph_size,
@@ -263,5 +306,13 @@ fn paint_line(
         );
     }
 
+    if let Some((strikethrough_start, strikethrough_style)) = current_strikethrough.take() {
+        cx.paint_strikethrough(
+            strikethrough_start,
+            last_line_end_x - strikethrough_start.x,
+            &strikethrough_style,
+        );
+    }
+
     Ok(())
 }

crates/gpui/src/text_system/line_wrapper.rs 🔗

@@ -225,6 +225,7 @@ mod tests {
                 font: font("Helvetica"),
                 color: Default::default(),
                 underline: Default::default(),
+                strikethrough: None,
                 background_color: None,
             };
             let bold = TextRun {
@@ -232,6 +233,7 @@ mod tests {
                 font: font("Helvetica").bold(),
                 color: Default::default(),
                 underline: Default::default(),
+                strikethrough: None,
                 background_color: None,
             };
 

crates/gpui/src/window/element_cx.rs 🔗

@@ -34,8 +34,8 @@ use crate::{
     InputHandler, IsZero, KeyContext, KeyEvent, LayoutId, MonochromeSprite, MouseEvent, PaintQuad,
     Path, Pixels, PlatformInputHandler, Point, PolychromeSprite, Quad, RenderGlyphParams,
     RenderImageParams, RenderSvgParams, Scene, Shadow, SharedString, Size, StackingContext,
-    StackingOrder, Style, Surface, TextStyleRefinement, Underline, UnderlineStyle, Window,
-    WindowContext, SUBPIXEL_VARIANTS,
+    StackingOrder, StrikethroughStyle, Style, Surface, TextStyleRefinement, Underline,
+    UnderlineStyle, Window, WindowContext, SUBPIXEL_VARIANTS,
 };
 
 type AnyMouseListener = Box<dyn FnMut(&dyn Any, DispatchPhase, &mut ElementContext) + 'static>;
@@ -758,6 +758,38 @@ impl<'a> ElementContext<'a> {
         );
     }
 
+    /// Paint a strikethrough into the scene for the next frame at the current z-index.
+    pub fn paint_strikethrough(
+        &mut self,
+        origin: Point<Pixels>,
+        width: Pixels,
+        style: &StrikethroughStyle,
+    ) {
+        let scale_factor = self.scale_factor();
+        let height = style.thickness;
+        let bounds = Bounds {
+            origin,
+            size: size(width, height),
+        };
+        let content_mask = self.content_mask();
+        let view_id = self.parent_view_id();
+
+        let window = &mut *self.window;
+        window.next_frame.scene.insert(
+            &window.next_frame.z_index_stack,
+            Underline {
+                view_id: view_id.into(),
+                layer_id: 0,
+                order: 0,
+                bounds: bounds.scale(scale_factor),
+                content_mask: content_mask.scale(scale_factor),
+                thickness: style.thickness.scale(scale_factor),
+                color: style.color.unwrap_or_default(),
+                wavy: false,
+            },
+        );
+    }
+
     /// Paints a monochrome (non-emoji) glyph into the scene for the next frame at the current z-index.
     ///
     /// The y component of the origin is the baseline of the glyph.

crates/outline/src/outline.rs 🔗

@@ -282,6 +282,7 @@ impl PickerDelegate for OutlineViewDelegate {
             line_height: relative(1.).into(),
             background_color: None,
             underline: None,
+            strikethrough: None,
             white_space: WhiteSpace::Normal,
         };
 

crates/search/src/buffer_search.rs 🔗

@@ -88,6 +88,7 @@ impl BufferSearchBar {
             line_height: relative(1.3).into(),
             background_color: None,
             underline: None,
+            strikethrough: None,
             white_space: WhiteSpace::Normal,
         };
 

crates/search/src/project_search.rs 🔗

@@ -1632,6 +1632,7 @@ impl ProjectSearchBar {
             line_height: relative(1.3).into(),
             background_color: None,
             underline: None,
+            strikethrough: None,
             white_space: WhiteSpace::Normal,
         };
 

crates/terminal_view/src/terminal_element.rs 🔗

@@ -362,6 +362,7 @@ impl TerminalElement {
                 ..text_style.font()
             },
             underline,
+            strikethrough: None,
         };
 
         if let Some((style, range)) = hyperlink {
@@ -414,6 +415,7 @@ impl TerminalElement {
                 color: Some(theme.colors().link_text_hover),
                 wavy: false,
             }),
+            strikethrough: None,
             fade_out: None,
         };
 
@@ -427,6 +429,7 @@ impl TerminalElement {
             white_space: WhiteSpace::Normal,
             // These are going to be overridden per-cell
             underline: None,
+            strikethrough: None,
             color: theme.colors().text,
             font_weight: FontWeight::NORMAL,
         };
@@ -545,6 +548,7 @@ impl TerminalElement {
                             color: theme.colors().terminal_background,
                             background_color: None,
                             underline: Default::default(),
+                            strikethrough: None,
                         }],
                     )
                     .unwrap()