Allow underlines to have different color than the text

Max Brunsfeld and Nathan Sobo created

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

Change summary

crates/editor/src/element.rs          |  8 ++--
crates/editor/src/lib.rs              |  2 
crates/gpui/examples/text.rs          |  4 +-
crates/gpui/src/elements/label.rs     |  4 +-
crates/gpui/src/fonts.rs              | 39 ++++++++++++++++++++++------
crates/gpui/src/platform/mac/fonts.rs | 12 ++++----
crates/gpui/src/text_layout.rs        | 34 ++++++++++++------------
crates/workspace/src/items.rs         |  2 
8 files changed, 63 insertions(+), 42 deletions(-)

Detailed changes

crates/editor/src/element.rs πŸ”—

@@ -394,7 +394,7 @@ impl EditorElement {
                     RunStyle {
                         font_id: style.text.font_id,
                         color: Color::black(),
-                        underline: false,
+                        underline: None,
                     },
                 )],
             )
@@ -435,7 +435,7 @@ impl EditorElement {
                         RunStyle {
                             font_id: style.text.font_id,
                             color,
-                            underline: false,
+                            underline: None,
                         },
                     )],
                 )));
@@ -476,7 +476,7 @@ impl EditorElement {
                             RunStyle {
                                 font_id: placeholder_style.font_id,
                                 color: placeholder_style.color,
-                                underline: false,
+                                underline: None,
                             },
                         )],
                     )
@@ -859,7 +859,7 @@ impl LayoutState {
                 RunStyle {
                     font_id: self.style.text.font_id,
                     color: Color::black(),
-                    underline: false,
+                    underline: None,
                 },
             )],
         )

crates/editor/src/lib.rs πŸ”—

@@ -2763,7 +2763,7 @@ impl EditorSettings {
                         font_size: 14.,
                         color: Color::from_u32(0xff0000ff),
                         font_properties,
-                        underline: false,
+                        underline: None,
                     },
                     placeholder_text: None,
                     background: Default::default(),

crates/gpui/examples/text.rs πŸ”—

@@ -62,7 +62,7 @@ impl gpui::Element for TextElement {
                 .select_font(family, &Default::default())
                 .unwrap(),
             color: Color::default(),
-            underline: false,
+            underline: None,
         };
         let bold = RunStyle {
             font_id: cx
@@ -76,7 +76,7 @@ impl gpui::Element for TextElement {
                 )
                 .unwrap(),
             color: Color::default(),
-            underline: false,
+            underline: None,
         };
 
         let text = "Hello world!";

crates/gpui/src/elements/label.rs πŸ”—

@@ -207,7 +207,7 @@ mod tests {
             "Menlo",
             12.,
             Default::default(),
-            false,
+            None,
             Color::black(),
             cx.font_cache(),
         )
@@ -216,7 +216,7 @@ mod tests {
             "Menlo",
             12.,
             *FontProperties::new().weight(Weight::BOLD),
-            false,
+            None,
             Color::new(255, 0, 0, 255),
             cx.font_cache(),
         )

crates/gpui/src/fonts.rs πŸ”—

@@ -27,14 +27,14 @@ pub struct TextStyle {
     pub font_id: FontId,
     pub font_size: f32,
     pub font_properties: Properties,
-    pub underline: bool,
+    pub underline: Option<Color>,
 }
 
 #[derive(Clone, Debug, Default)]
 pub struct HighlightStyle {
     pub color: Color,
     pub font_properties: Properties,
-    pub underline: bool,
+    pub underline: Option<Color>,
 }
 
 #[allow(non_camel_case_types)]
@@ -64,7 +64,7 @@ struct TextStyleJson {
     #[serde(default)]
     italic: bool,
     #[serde(default)]
-    underline: bool,
+    underline: UnderlineStyleJson,
 }
 
 #[derive(Deserialize)]
@@ -74,7 +74,14 @@ struct HighlightStyleJson {
     #[serde(default)]
     italic: bool,
     #[serde(default)]
-    underline: bool,
+    underline: UnderlineStyleJson,
+}
+
+#[derive(Deserialize)]
+#[serde(untagged)]
+enum UnderlineStyleJson {
+    Underlined(bool),
+    UnderlinedWithColor(Color),
 }
 
 impl TextStyle {
@@ -82,7 +89,7 @@ impl TextStyle {
         font_family_name: impl Into<Arc<str>>,
         font_size: f32,
         font_properties: Properties,
-        underline: bool,
+        underline: Option<Color>,
         color: Color,
         font_cache: &FontCache,
     ) -> anyhow::Result<Self> {
@@ -116,7 +123,7 @@ impl TextStyle {
                     json.family,
                     json.size,
                     font_properties,
-                    json.underline,
+                    underline_from_json(json.underline, json.color),
                     json.color,
                     font_cache,
                 )
@@ -167,6 +174,12 @@ impl From<TextStyle> for HighlightStyle {
     }
 }
 
+impl Default for UnderlineStyleJson {
+    fn default() -> Self {
+        Self::Underlined(false)
+    }
+}
+
 impl Default for TextStyle {
     fn default() -> Self {
         FONT_CACHE.with(|font_cache| {
@@ -199,7 +212,7 @@ impl HighlightStyle {
         Self {
             color: json.color,
             font_properties,
-            underline: json.underline,
+            underline: underline_from_json(json.underline, json.color),
         }
     }
 }
@@ -209,7 +222,7 @@ impl From<Color> for HighlightStyle {
         Self {
             color,
             font_properties: Default::default(),
-            underline: false,
+            underline: None,
         }
     }
 }
@@ -248,12 +261,20 @@ impl<'de> Deserialize<'de> for HighlightStyle {
             Ok(Self {
                 color: serde_json::from_value(json).map_err(de::Error::custom)?,
                 font_properties: Properties::new(),
-                underline: false,
+                underline: None,
             })
         }
     }
 }
 
+fn underline_from_json(json: UnderlineStyleJson, text_color: Color) -> Option<Color> {
+    match json {
+        UnderlineStyleJson::Underlined(false) => None,
+        UnderlineStyleJson::Underlined(true) => Some(text_color),
+        UnderlineStyleJson::UnderlinedWithColor(color) => Some(color),
+    }
+}
+
 fn properties_from_json(weight: Option<WeightJson>, italic: bool) -> Properties {
     let weight = match weight.unwrap_or(WeightJson::normal) {
         WeightJson::thin => Weight::THIN,

crates/gpui/src/platform/mac/fonts.rs πŸ”—

@@ -417,21 +417,21 @@ mod tests {
         let menlo_regular = RunStyle {
             font_id: fonts.select_font(&menlo, &Properties::new()).unwrap(),
             color: Default::default(),
-            underline: false,
+            underline: None,
         };
         let menlo_italic = RunStyle {
             font_id: fonts
                 .select_font(&menlo, &Properties::new().style(Style::Italic))
                 .unwrap(),
             color: Default::default(),
-            underline: false,
+            underline: None,
         };
         let menlo_bold = RunStyle {
             font_id: fonts
                 .select_font(&menlo, &Properties::new().weight(Weight::BOLD))
                 .unwrap(),
             color: Default::default(),
-            underline: false,
+            underline: None,
         };
         assert_ne!(menlo_regular, menlo_italic);
         assert_ne!(menlo_regular, menlo_bold);
@@ -458,13 +458,13 @@ mod tests {
         let zapfino_regular = RunStyle {
             font_id: fonts.select_font(&zapfino, &Properties::new())?,
             color: Default::default(),
-            underline: false,
+            underline: None,
         };
         let menlo = fonts.load_family("Menlo")?;
         let menlo_regular = RunStyle {
             font_id: fonts.select_font(&menlo, &Properties::new())?,
             color: Default::default(),
-            underline: false,
+            underline: None,
         };
 
         let text = "This is, m𐍈re 𐍈r less, Zapfino!𐍈";
@@ -543,7 +543,7 @@ mod tests {
         let style = RunStyle {
             font_id: fonts.select_font(&font_ids, &Default::default()).unwrap(),
             color: Default::default(),
-            underline: false,
+            underline: None,
         };
 
         let line = "\u{feff}";

crates/gpui/src/text_layout.rs πŸ”—

@@ -28,7 +28,7 @@ pub struct TextLayoutCache {
 pub struct RunStyle {
     pub color: Color,
     pub font_id: FontId,
-    pub underline: bool,
+    pub underline: Option<Color>,
 }
 
 impl TextLayoutCache {
@@ -167,7 +167,7 @@ impl<'a> Hash for CacheKeyRef<'a> {
 #[derive(Default, Debug)]
 pub struct Line {
     layout: Arc<LineLayout>,
-    style_runs: SmallVec<[(u32, Color, bool); 32]>,
+    style_runs: SmallVec<[(u32, Color, Option<Color>); 32]>,
 }
 
 #[derive(Default, Debug)]
@@ -249,7 +249,7 @@ impl Line {
         let mut style_runs = self.style_runs.iter();
         let mut run_end = 0;
         let mut color = Color::black();
-        let mut underline_start = None;
+        let mut underline = None;
 
         for run in &self.layout.runs {
             let max_glyph_width = cx
@@ -268,24 +268,24 @@ impl Line {
                 }
 
                 if glyph.index >= run_end {
-                    if let Some((run_len, run_color, run_underlined)) = style_runs.next() {
-                        if let Some(underline_origin) = underline_start {
-                            if !*run_underlined || *run_color != color {
+                    if let Some((run_len, run_color, run_underline_color)) = style_runs.next() {
+                        if let Some((underline_origin, underline_color)) = underline {
+                            if *run_underline_color != Some(underline_color) {
                                 cx.scene.push_underline(scene::Quad {
                                     bounds: RectF::from_points(
                                         underline_origin,
                                         glyph_origin + vec2f(0., 1.),
                                     ),
-                                    background: Some(color),
+                                    background: Some(underline_color),
                                     border: Default::default(),
                                     corner_radius: 0.,
                                 });
-                                underline_start = None;
+                                underline = None;
                             }
                         }
 
-                        if *run_underlined {
-                            underline_start.get_or_insert(glyph_origin);
+                        if let Some(run_underline_color) = run_underline_color {
+                            underline.get_or_insert((glyph_origin, *run_underline_color));
                         }
 
                         run_end += *run_len as usize;
@@ -293,13 +293,13 @@ impl Line {
                     } else {
                         run_end = self.layout.len;
                         color = Color::black();
-                        if let Some(underline_origin) = underline_start.take() {
+                        if let Some((underline_origin, underline_color)) = underline.take() {
                             cx.scene.push_underline(scene::Quad {
                                 bounds: RectF::from_points(
                                     underline_origin,
                                     glyph_origin + vec2f(0., 1.),
                                 ),
-                                background: Some(color),
+                                background: Some(underline_color),
                                 border: Default::default(),
                                 corner_radius: 0.,
                             });
@@ -317,12 +317,12 @@ impl Line {
             }
         }
 
-        if let Some(underline_start) = underline_start.take() {
+        if let Some((underline_start, underline_color)) = underline.take() {
             let line_end = origin + baseline_offset + vec2f(self.layout.width, 0.);
 
             cx.scene.push_underline(scene::Quad {
                 bounds: RectF::from_points(underline_start, line_end + vec2f(0., 1.)),
-                background: Some(color),
+                background: Some(underline_color),
                 border: Default::default(),
                 corner_radius: 0.,
             });
@@ -597,7 +597,7 @@ impl LineWrapper {
                     RunStyle {
                         font_id: self.font_id,
                         color: Default::default(),
-                        underline: false,
+                        underline: None,
                     },
                 )],
             )
@@ -681,7 +681,7 @@ mod tests {
         let normal = RunStyle {
             font_id,
             color: Default::default(),
-            underline: false,
+            underline: None,
         };
         let bold = RunStyle {
             font_id: font_cache
@@ -694,7 +694,7 @@ mod tests {
                 )
                 .unwrap(),
             color: Default::default(),
-            underline: false,
+            underline: None,
         };
 
         let text = "aa bbb cccc ddddd eeee";

crates/workspace/src/items.rs πŸ”—

@@ -37,7 +37,7 @@ impl Item for Buffer {
                     font_id,
                     font_size,
                     font_properties,
-                    underline: false,
+                    underline: None,
                 };
                 EditorSettings {
                     tab_size: settings.tab_size,