Start work on underlined text

Max Brunsfeld and Nathan Sobo created

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

Change summary

gpui/examples/text.rs          |  47 ++++++----
gpui/src/elements/label.rs     |  78 ++++++++++++-----
gpui/src/elements/text.rs      |   2 
gpui/src/fonts.rs              |  23 +++++
gpui/src/platform.rs           |  10 -
gpui/src/platform/mac/fonts.rs |  94 ++++++++++++----------
gpui/src/text_layout.rs        | 151 +++++++++++++++++++++++++----------
zed/assets/themes/_base.toml   |   2 
zed/assets/themes/dark.toml    |   2 
zed/src/editor.rs              |  58 +++++++++++--
zed/src/theme.rs               |   2 
11 files changed, 317 insertions(+), 152 deletions(-)

Detailed changes

gpui/examples/text.rs πŸ”—

@@ -1,6 +1,7 @@
 use gpui::{
     color::Color,
     fonts::{Properties, Weight},
+    text_layout::RunStyle,
     DebugContext, Element as _, Quad,
 };
 use log::LevelFilter;
@@ -55,31 +56,39 @@ impl gpui::Element for TextElement {
     ) -> Self::PaintState {
         let font_size = 12.;
         let family = cx.font_cache.load_family(&["SF Pro Display"]).unwrap();
-        let normal = cx
-            .font_cache
-            .select_font(family, &Default::default())
-            .unwrap();
-        let bold = cx
-            .font_cache
-            .select_font(
-                family,
-                &Properties {
-                    weight: Weight::BOLD,
-                    ..Default::default()
-                },
-            )
-            .unwrap();
+        let normal = RunStyle {
+            font_id: cx
+                .font_cache
+                .select_font(family, &Default::default())
+                .unwrap(),
+            color: Color::default(),
+            underline: false,
+        };
+        let bold = RunStyle {
+            font_id: cx
+                .font_cache
+                .select_font(
+                    family,
+                    &Properties {
+                        weight: Weight::BOLD,
+                        ..Default::default()
+                    },
+                )
+                .unwrap(),
+            color: Color::default(),
+            underline: false,
+        };
 
         let text = "Hello world!";
         let line = cx.text_layout_cache.layout_str(
             text,
             font_size,
             &[
-                (1, normal, Color::default()),
-                (1, bold, Color::default()),
-                (1, normal, Color::default()),
-                (1, bold, Color::default()),
-                (text.len() - 4, normal, Color::default()),
+                (1, normal.clone()),
+                (1, bold.clone()),
+                (1, normal.clone()),
+                (1, bold.clone()),
+                (text.len() - 4, normal.clone()),
             ],
         );
 

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

@@ -1,12 +1,11 @@
 use crate::{
-    color::Color,
-    fonts::{FontId, TextStyle},
+    fonts::TextStyle,
     geometry::{
         rect::RectF,
         vector::{vec2f, Vector2F},
     },
     json::{ToJson, Value},
-    text_layout::Line,
+    text_layout::{Line, RunStyle},
     DebugContext, Element, Event, EventContext, LayoutContext, PaintContext, SizeConstraint,
 };
 use serde::Deserialize;
@@ -48,10 +47,17 @@ impl Label {
         self
     }
 
-    fn compute_runs(&self) -> SmallVec<[(usize, FontId, Color); 8]> {
+    fn compute_runs(&self) -> SmallVec<[(usize, RunStyle); 8]> {
         let font_id = self.style.text.font_id;
         if self.highlight_indices.is_empty() {
-            return smallvec![(self.text.len(), font_id, self.style.text.color)];
+            return smallvec![(
+                self.text.len(),
+                RunStyle {
+                    font_id,
+                    color: self.style.text.color,
+                    underline: false,
+                }
+            )];
         }
 
         let highlight_font_id = self
@@ -62,25 +68,31 @@ impl Label {
 
         let mut highlight_indices = self.highlight_indices.iter().copied().peekable();
         let mut runs = SmallVec::new();
+        let highlight_style = self
+            .style
+            .highlight_text
+            .as_ref()
+            .unwrap_or(&self.style.text);
 
         for (char_ix, c) in self.text.char_indices() {
             let mut font_id = font_id;
             let mut color = self.style.text.color;
+            let mut underline = self.style.text.underline;
             if let Some(highlight_ix) = highlight_indices.peek() {
                 if char_ix == *highlight_ix {
                     font_id = highlight_font_id;
-                    color = self
-                        .style
-                        .highlight_text
-                        .as_ref()
-                        .unwrap_or(&self.style.text)
-                        .color;
+                    color = highlight_style.color;
+                    underline = highlight_style.underline;
                     highlight_indices.next();
                 }
             }
 
-            let push_new_run = if let Some((last_len, last_font_id, last_color)) = runs.last_mut() {
-                if font_id == *last_font_id && color == *last_color {
+            let last_run: Option<&mut (usize, RunStyle)> = runs.last_mut();
+            let push_new_run = if let Some((last_len, last_style)) = last_run {
+                if font_id == last_style.font_id
+                    && color == last_style.color
+                    && underline == last_style.underline
+                {
                     *last_len += c.len_utf8();
                     false
                 } else {
@@ -91,7 +103,14 @@ impl Label {
             };
 
             if push_new_run {
-                runs.push((c.len_utf8(), font_id, color));
+                runs.push((
+                    c.len_utf8(),
+                    RunStyle {
+                        font_id,
+                        color,
+                        underline,
+                    },
+                ));
             }
         }
 
@@ -177,6 +196,7 @@ impl ToJson for LabelStyle {
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::color::Color;
     use crate::fonts::{Properties as FontProperties, Weight};
 
     #[crate::test(self)]
@@ -185,6 +205,7 @@ mod tests {
             "Menlo",
             12.,
             Default::default(),
+            false,
             Color::black(),
             cx.font_cache(),
         )
@@ -193,6 +214,7 @@ mod tests {
             "Menlo",
             12.,
             *FontProperties::new().weight(Weight::BOLD),
+            false,
             Color::new(255, 0, 0, 255),
             cx.font_cache(),
         )
@@ -212,21 +234,27 @@ mod tests {
             ".αβγδΡ.ⓐⓑ".len(),
         ]);
 
+        let default_run_style = RunStyle {
+            font_id: default_style.font_id,
+            color: default_style.color,
+            underline: default_style.underline,
+        };
+        let highlight_run_style = RunStyle {
+            font_id: highlight_style.font_id,
+            color: highlight_style.color,
+            underline: highlight_style.underline,
+        };
         let runs = label.compute_runs();
         assert_eq!(
             runs.as_slice(),
             &[
-                (".Ξ±".len(), default_style.font_id, default_style.color),
-                ("Ξ²Ξ³".len(), highlight_style.font_id, highlight_style.color),
-                ("Ξ΄".len(), default_style.font_id, default_style.color),
-                ("Ξ΅".len(), highlight_style.font_id, highlight_style.color),
-                (".ⓐ".len(), default_style.font_id, default_style.color),
-                ("β“‘β“’".len(), highlight_style.font_id, highlight_style.color),
-                (
-                    "β““β“”.abcde.".len(),
-                    default_style.font_id,
-                    default_style.color
-                ),
+                (".Ξ±".len(), default_run_style),
+                ("Ξ²Ξ³".len(), highlight_run_style),
+                ("Ξ΄".len(), default_run_style),
+                ("Ξ΅".len(), highlight_run_style),
+                (".ⓐ".len(), default_run_style),
+                ("β“‘β“’".len(), highlight_run_style),
+                ("β““β“”.abcde.".len(), default_run_style),
             ]
         );
     }

gpui/src/elements/text.rs πŸ”—

@@ -52,7 +52,7 @@ impl Element for Text {
             let shaped_line = cx.text_layout_cache.layout_str(
                 line,
                 self.style.font_size,
-                &[(line.len(), font_id, self.style.color)],
+                &[(line.len(), self.style.to_run())],
             );
             let wrap_boundaries = wrapper
                 .wrap_shaped_line(line, &shaped_line, constraint.max.x())

gpui/src/fonts.rs πŸ”—

@@ -1,6 +1,7 @@
 use crate::{
     color::Color,
     json::{json, ToJson},
+    text_layout::RunStyle,
     FontCache,
 };
 use anyhow::anyhow;
@@ -24,12 +25,14 @@ pub struct TextStyle {
     pub font_id: FontId,
     pub font_size: f32,
     pub font_properties: Properties,
+    pub underline: bool,
 }
 
 #[derive(Clone, Debug, Default)]
 pub struct HighlightStyle {
     pub color: Color,
     pub font_properties: Properties,
+    pub underline: bool,
 }
 
 #[allow(non_camel_case_types)]
@@ -55,9 +58,11 @@ struct TextStyleJson {
     color: Color,
     family: String,
     weight: Option<WeightJson>,
+    size: f32,
     #[serde(default)]
     italic: bool,
-    size: f32,
+    #[serde(default)]
+    underline: bool,
 }
 
 #[derive(Deserialize)]
@@ -66,6 +71,8 @@ struct HighlightStyleJson {
     weight: Option<WeightJson>,
     #[serde(default)]
     italic: bool,
+    #[serde(default)]
+    underline: bool,
 }
 
 impl TextStyle {
@@ -73,6 +80,7 @@ impl TextStyle {
         font_family_name: impl Into<Arc<str>>,
         font_size: f32,
         font_properties: Properties,
+        underline: bool,
         color: Color,
         font_cache: &FontCache,
     ) -> anyhow::Result<Self> {
@@ -85,9 +93,18 @@ impl TextStyle {
             font_id,
             font_size,
             font_properties,
+            underline,
         })
     }
 
+    pub fn to_run(&self) -> RunStyle {
+        RunStyle {
+            font_id: self.font_id,
+            color: self.color,
+            underline: self.underline,
+        }
+    }
+
     fn from_json(json: TextStyleJson) -> anyhow::Result<Self> {
         FONT_CACHE.with(|font_cache| {
             if let Some(font_cache) = font_cache.borrow().as_ref() {
@@ -96,6 +113,7 @@ impl TextStyle {
                     json.family,
                     json.size,
                     font_properties,
+                    json.underline,
                     json.color,
                     font_cache,
                 )
@@ -114,6 +132,7 @@ impl HighlightStyle {
         Self {
             color: json.color,
             font_properties,
+            underline: json.underline,
         }
     }
 }
@@ -123,6 +142,7 @@ impl From<Color> for HighlightStyle {
         Self {
             color,
             font_properties: Default::default(),
+            underline: false,
         }
     }
 }
@@ -161,6 +181,7 @@ 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,
             })
         }
     }

gpui/src/platform.rs πŸ”—

@@ -8,14 +8,13 @@ pub mod current {
 }
 
 use crate::{
-    color::Color,
     executor,
     fonts::{FontId, GlyphId, Metrics as FontMetrics, Properties as FontProperties},
     geometry::{
         rect::{RectF, RectI},
         vector::{vec2f, Vector2F},
     },
-    text_layout::LineLayout,
+    text_layout::{LineLayout, RunStyle},
     AnyAction, ClipboardItem, Menu, Scene,
 };
 use anyhow::Result;
@@ -146,12 +145,7 @@ pub trait FontSystem: Send + Sync {
         subpixel_shift: Vector2F,
         scale_factor: f32,
     ) -> Option<(RectI, Vec<u8>)>;
-    fn layout_line(
-        &self,
-        text: &str,
-        font_size: f32,
-        runs: &[(usize, FontId, Color)],
-    ) -> LineLayout;
+    fn layout_line(&self, text: &str, font_size: f32, runs: &[(usize, RunStyle)]) -> LineLayout;
     fn wrap_line(&self, text: &str, font_id: FontId, font_size: f32, width: f32) -> Vec<usize>;
 }
 

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

@@ -1,5 +1,4 @@
 use crate::{
-    color::Color,
     fonts::{FontId, GlyphId, Metrics, Properties},
     geometry::{
         rect::{RectF, RectI},
@@ -7,7 +6,7 @@ use crate::{
         vector::{vec2f, vec2i, Vector2F},
     },
     platform,
-    text_layout::{Glyph, LineLayout, Run},
+    text_layout::{Glyph, LineLayout, Run, RunStyle},
 };
 use cocoa::appkit::{CGFloat, CGPoint};
 use core_foundation::{
@@ -87,12 +86,7 @@ impl platform::FontSystem for FontSystem {
             .rasterize_glyph(font_id, font_size, glyph_id, subpixel_shift, scale_factor)
     }
 
-    fn layout_line(
-        &self,
-        text: &str,
-        font_size: f32,
-        runs: &[(usize, FontId, Color)],
-    ) -> LineLayout {
+    fn layout_line(&self, text: &str, font_size: f32, runs: &[(usize, RunStyle)]) -> LineLayout {
         self.0.read().layout_line(text, font_size, runs)
     }
 
@@ -210,12 +204,7 @@ impl FontSystemState {
         }
     }
 
-    fn layout_line(
-        &self,
-        text: &str,
-        font_size: f32,
-        runs: &[(usize, FontId, Color)],
-    ) -> LineLayout {
+    fn layout_line(&self, text: &str, font_size: f32, runs: &[(usize, RunStyle)]) -> LineLayout {
         let font_id_attr_name = CFString::from_static_string("zed_font_id");
 
         // Construct the attributed string, converting UTF8 ranges to UTF16 ranges.
@@ -227,20 +216,20 @@ impl FontSystemState {
             let last_run: RefCell<Option<(usize, FontId)>> = Default::default();
             let font_runs = runs
                 .iter()
-                .filter_map(|(len, font_id, _)| {
+                .filter_map(|(len, style)| {
                     let mut last_run = last_run.borrow_mut();
                     if let Some((last_len, last_font_id)) = last_run.as_mut() {
-                        if font_id == last_font_id {
+                        if style.font_id == *last_font_id {
                             *last_len += *len;
                             None
                         } else {
                             let result = (*last_len, *last_font_id);
                             *last_len = *len;
-                            *last_font_id = *font_id;
+                            *last_font_id = style.font_id;
                             Some(result)
                         }
                     } else {
-                        *last_run = Some((*len, *font_id));
+                        *last_run = Some((*len, style.font_id));
                         None
                     }
                 })
@@ -415,9 +404,8 @@ extern "C" {
 
 #[cfg(test)]
 mod tests {
-    use crate::MutableAppContext;
-
     use super::*;
+    use crate::MutableAppContext;
     use font_kit::properties::{Style, Weight};
     use platform::FontSystem as _;
 
@@ -426,13 +414,25 @@ mod tests {
         // This is failing intermittently on CI and we don't have time to figure it out
         let fonts = FontSystem::new();
         let menlo = fonts.load_family("Menlo").unwrap();
-        let menlo_regular = fonts.select_font(&menlo, &Properties::new()).unwrap();
-        let menlo_italic = fonts
-            .select_font(&menlo, &Properties::new().style(Style::Italic))
-            .unwrap();
-        let menlo_bold = fonts
-            .select_font(&menlo, &Properties::new().weight(Weight::BOLD))
-            .unwrap();
+        let menlo_regular = RunStyle {
+            font_id: fonts.select_font(&menlo, &Properties::new()).unwrap(),
+            color: Default::default(),
+            underline: false,
+        };
+        let menlo_italic = RunStyle {
+            font_id: fonts
+                .select_font(&menlo, &Properties::new().style(Style::Italic))
+                .unwrap(),
+            color: Default::default(),
+            underline: false,
+        };
+        let menlo_bold = RunStyle {
+            font_id: fonts
+                .select_font(&menlo, &Properties::new().weight(Weight::BOLD))
+                .unwrap(),
+            color: Default::default(),
+            underline: false,
+        };
         assert_ne!(menlo_regular, menlo_italic);
         assert_ne!(menlo_regular, menlo_bold);
         assert_ne!(menlo_italic, menlo_bold);
@@ -440,18 +440,14 @@ mod tests {
         let line = fonts.layout_line(
             "hello world",
             16.0,
-            &[
-                (2, menlo_bold, Default::default()),
-                (4, menlo_italic, Default::default()),
-                (5, menlo_regular, Default::default()),
-            ],
+            &[(2, menlo_bold), (4, menlo_italic), (5, menlo_regular)],
         );
         assert_eq!(line.runs.len(), 3);
-        assert_eq!(line.runs[0].font_id, menlo_bold);
+        assert_eq!(line.runs[0].font_id, menlo_bold.font_id);
         assert_eq!(line.runs[0].glyphs.len(), 2);
-        assert_eq!(line.runs[1].font_id, menlo_italic);
+        assert_eq!(line.runs[1].font_id, menlo_italic.font_id);
         assert_eq!(line.runs[1].glyphs.len(), 4);
-        assert_eq!(line.runs[2].font_id, menlo_regular);
+        assert_eq!(line.runs[2].font_id, menlo_regular.font_id);
         assert_eq!(line.runs[2].glyphs.len(), 5);
     }
 
@@ -459,18 +455,26 @@ mod tests {
     fn test_glyph_offsets() -> anyhow::Result<()> {
         let fonts = FontSystem::new();
         let zapfino = fonts.load_family("Zapfino")?;
-        let zapfino_regular = fonts.select_font(&zapfino, &Properties::new())?;
+        let zapfino_regular = RunStyle {
+            font_id: fonts.select_font(&zapfino, &Properties::new())?,
+            color: Default::default(),
+            underline: false,
+        };
         let menlo = fonts.load_family("Menlo")?;
-        let menlo_regular = fonts.select_font(&menlo, &Properties::new())?;
+        let menlo_regular = RunStyle {
+            font_id: fonts.select_font(&menlo, &Properties::new())?,
+            color: Default::default(),
+            underline: false,
+        };
 
         let text = "This is, m𐍈re 𐍈r less, Zapfino!𐍈";
         let line = fonts.layout_line(
             text,
             16.0,
             &[
-                (9, zapfino_regular, Color::default()),
-                (13, menlo_regular, Color::default()),
-                (text.len() - 22, zapfino_regular, Color::default()),
+                (9, zapfino_regular),
+                (13, menlo_regular),
+                (text.len() - 22, zapfino_regular),
             ],
         );
         assert_eq!(
@@ -536,15 +540,19 @@ mod tests {
     fn test_layout_line_bom_char() {
         let fonts = FontSystem::new();
         let font_ids = fonts.load_family("Helvetica").unwrap();
-        let font_id = fonts.select_font(&font_ids, &Default::default()).unwrap();
+        let style = RunStyle {
+            font_id: fonts.select_font(&font_ids, &Default::default()).unwrap(),
+            color: Default::default(),
+            underline: false,
+        };
 
         let line = "\u{feff}";
-        let layout = fonts.layout_line(line, 16., &[(line.len(), font_id, Default::default())]);
+        let layout = fonts.layout_line(line, 16., &[(line.len(), style)]);
         assert_eq!(layout.len, line.len());
         assert!(layout.runs.is_empty());
 
         let line = "a\u{feff}b";
-        let layout = fonts.layout_line(line, 16., &[(line.len(), font_id, Default::default())]);
+        let layout = fonts.layout_line(line, 16., &[(line.len(), style)]);
         assert_eq!(layout.len, line.len());
         assert_eq!(layout.runs.len(), 1);
         assert_eq!(layout.runs[0].glyphs.len(), 2);

gpui/src/text_layout.rs πŸ”—

@@ -24,6 +24,13 @@ pub struct TextLayoutCache {
     fonts: Arc<dyn platform::FontSystem>,
 }
 
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub struct RunStyle {
+    pub color: Color,
+    pub font_id: FontId,
+    pub underline: bool,
+}
+
 impl TextLayoutCache {
     pub fn new(fonts: Arc<dyn platform::FontSystem>) -> Self {
         Self {
@@ -44,7 +51,7 @@ impl TextLayoutCache {
         &'a self,
         text: &'a str,
         font_size: f32,
-        runs: &'a [(usize, FontId, Color)],
+        runs: &'a [(usize, RunStyle)],
     ) -> Line {
         let key = &CacheKeyRef {
             text,
@@ -95,7 +102,7 @@ impl<'a> Hash for (dyn CacheKey + 'a) {
 struct CacheKeyValue {
     text: String,
     font_size: OrderedFloat<f32>,
-    runs: SmallVec<[(usize, FontId, Color); 1]>,
+    runs: SmallVec<[(usize, RunStyle); 1]>,
 }
 
 impl CacheKey for CacheKeyValue {
@@ -120,11 +127,11 @@ impl<'a> Borrow<dyn CacheKey + 'a> for CacheKeyValue {
     }
 }
 
-#[derive(Copy, Clone, PartialEq, Eq, Hash)]
+#[derive(Copy, Clone)]
 struct CacheKeyRef<'a> {
     text: &'a str,
     font_size: OrderedFloat<f32>,
-    runs: &'a [(usize, FontId, Color)],
+    runs: &'a [(usize, RunStyle)],
 }
 
 impl<'a> CacheKey for CacheKeyRef<'a> {
@@ -133,10 +140,34 @@ impl<'a> CacheKey for CacheKeyRef<'a> {
     }
 }
 
+impl<'a> PartialEq for CacheKeyRef<'a> {
+    fn eq(&self, other: &Self) -> bool {
+        self.text == other.text
+            && self.font_size == other.font_size
+            && self.runs.len() == other.runs.len()
+            && self.runs.iter().zip(other.runs.iter()).all(
+                |((len_a, style_a), (len_b, style_b))| {
+                    len_a == len_b && style_a.font_id == style_b.font_id
+                },
+            )
+    }
+}
+
+impl<'a> Hash for CacheKeyRef<'a> {
+    fn hash<H: Hasher>(&self, state: &mut H) {
+        self.text.hash(state);
+        self.font_size.hash(state);
+        for (len, style_id) in self.runs {
+            len.hash(state);
+            style_id.font_id.hash(state);
+        }
+    }
+}
+
 #[derive(Default, Debug)]
 pub struct Line {
     layout: Arc<LineLayout>,
-    color_runs: SmallVec<[(u32, Color); 32]>,
+    style_runs: SmallVec<[(u32, Color, bool); 32]>,
 }
 
 #[derive(Default, Debug)]
@@ -163,12 +194,12 @@ pub struct Glyph {
 }
 
 impl Line {
-    fn new(layout: Arc<LineLayout>, runs: &[(usize, FontId, Color)]) -> Self {
-        let mut color_runs = SmallVec::new();
-        for (len, _, color) in runs {
-            color_runs.push((*len as u32, *color));
+    fn new(layout: Arc<LineLayout>, runs: &[(usize, RunStyle)]) -> Self {
+        let mut style_runs = SmallVec::new();
+        for (len, style) in runs {
+            style_runs.push((*len as u32, style.color, style.underline));
         }
-        Self { layout, color_runs }
+        Self { layout, style_runs }
     }
 
     pub fn runs(&self) -> &[Run] {
@@ -213,11 +244,12 @@ impl Line {
         cx: &mut PaintContext,
     ) {
         let padding_top = (line_height - self.layout.ascent - self.layout.descent) / 2.;
-        let baseline_origin = vec2f(0., padding_top + self.layout.ascent);
+        let baseline_offset = vec2f(0., padding_top + self.layout.ascent);
 
-        let mut color_runs = self.color_runs.iter();
-        let mut color_end = 0;
+        let mut style_runs = self.style_runs.iter();
+        let mut run_end = 0;
         let mut color = Color::black();
+        let mut underline_start = None;
 
         for run in &self.layout.runs {
             let max_glyph_width = cx
@@ -226,7 +258,7 @@ impl Line {
                 .x();
 
             for glyph in &run.glyphs {
-                let glyph_origin = origin + baseline_origin + glyph.position;
+                let glyph_origin = origin + baseline_offset + glyph.position;
 
                 if glyph_origin.x() + max_glyph_width < visible_bounds.origin().x() {
                     continue;
@@ -235,12 +267,31 @@ impl Line {
                     break;
                 }
 
-                if glyph.index >= color_end {
-                    if let Some(next_run) = color_runs.next() {
-                        color_end += next_run.0 as usize;
-                        color = next_run.1;
+                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 {
+                                cx.scene.push_quad(scene::Quad {
+                                    bounds: RectF::from_points(
+                                        underline_origin,
+                                        glyph_origin + vec2f(0., 1.),
+                                    ),
+                                    background: Some(color),
+                                    border: Default::default(),
+                                    corner_radius: 0.,
+                                });
+                                underline_start = None;
+                            }
+                        }
+
+                        if *run_underlined {
+                            underline_start.get_or_insert(glyph_origin);
+                        }
+
+                        run_end += *run_len as usize;
+                        color = *run_color;
                     } else {
-                        color_end = self.layout.len;
+                        run_end = self.layout.len;
                         color = Color::black();
                     }
                 }
@@ -253,6 +304,16 @@ impl Line {
                     color,
                 });
             }
+
+            if let Some(underline_start) = underline_start.take() {
+                let line_end = origin + baseline_offset + vec2f(self.layout.width, 0.);
+                cx.scene.push_quad(scene::Quad {
+                    bounds: RectF::from_points(underline_start, line_end + vec2f(0., 1.)),
+                    background: Some(color),
+                    border: Default::default(),
+                    corner_radius: 0.,
+                });
+            }
         }
     }
 
@@ -268,7 +329,7 @@ impl Line {
         let baseline_origin = vec2f(0., padding_top + self.layout.ascent);
 
         let mut boundaries = boundaries.into_iter().peekable();
-        let mut color_runs = self.color_runs.iter();
+        let mut color_runs = self.style_runs.iter();
         let mut color_end = 0;
         let mut color = Color::black();
 
@@ -519,7 +580,14 @@ impl LineWrapper {
             .layout_line(
                 &c.to_string(),
                 self.font_size,
-                &[(1, self.font_id, Default::default())],
+                &[(
+                    1,
+                    RunStyle {
+                        font_id: self.font_id,
+                        color: Default::default(),
+                        underline: false,
+                    },
+                )],
             )
             .width
     }
@@ -528,10 +596,7 @@ impl LineWrapper {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::{
-        color::Color,
-        fonts::{Properties, Weight},
-    };
+    use crate::fonts::{Properties, Weight};
 
     #[crate::test(self)]
     fn test_wrap_line(cx: &mut crate::MutableAppContext) {
@@ -600,28 +665,30 @@ mod tests {
 
         let family = font_cache.load_family(&["Helvetica"]).unwrap();
         let font_id = font_cache.select_font(family, &Default::default()).unwrap();
-        let normal = font_cache.select_font(family, &Default::default()).unwrap();
-        let bold = font_cache
-            .select_font(
-                family,
-                &Properties {
-                    weight: Weight::BOLD,
-                    ..Default::default()
-                },
-            )
-            .unwrap();
+        let normal = RunStyle {
+            font_id,
+            color: Default::default(),
+            underline: false,
+        };
+        let bold = RunStyle {
+            font_id: font_cache
+                .select_font(
+                    family,
+                    &Properties {
+                        weight: Weight::BOLD,
+                        ..Default::default()
+                    },
+                )
+                .unwrap(),
+            color: Default::default(),
+            underline: false,
+        };
 
         let text = "aa bbb cccc ddddd eeee";
         let line = text_layout_cache.layout_str(
             text,
             16.0,
-            &[
-                (4, normal, Color::default()),
-                (5, bold, Color::default()),
-                (6, normal, Color::default()),
-                (1, bold, Color::default()),
-                (7, normal, Color::default()),
-            ],
+            &[(4, normal), (5, bold), (6, normal), (1, bold), (7, normal)],
         );
 
         let mut wrapper = LineWrapper::new(font_id, 16., font_system);

zed/assets/themes/_base.toml πŸ”—

@@ -2,7 +2,7 @@
 background = "$surface.0"
 
 [workspace.tab]
-text = "$text.2"
+text = { extends = "$text.2", underline = true }
 padding = { left = 10, right = 10 }
 icon_close = "$text.0.color"
 icon_dirty = "$status.info"

zed/assets/themes/dark.toml πŸ”—

@@ -23,7 +23,7 @@ bad = "#b7372e"
 
 [syntax]
 keyword = { color = "#0086c0", weight = "bold" }
-function = "#dcdcaa"
+function = { color = "#dcdcaa", underline = true }
 string = "#cb8f77"
 type = "#4ec9b0"
 number = "#b5cea8"

zed/src/editor.rs πŸ”—

@@ -17,10 +17,15 @@ pub use display_map::DisplayPoint;
 use display_map::*;
 pub use element::*;
 use gpui::{
-    action, color::Color, font_cache::FamilyId, fonts::Properties as FontProperties,
-    geometry::vector::Vector2F, keymap::Binding, text_layout, AppContext, ClipboardItem, Element,
-    ElementBox, Entity, FontCache, ModelHandle, MutableAppContext, RenderContext, Task,
-    TextLayoutCache, View, ViewContext, WeakViewHandle,
+    action,
+    color::Color,
+    font_cache::FamilyId,
+    fonts::Properties as FontProperties,
+    geometry::vector::Vector2F,
+    keymap::Binding,
+    text_layout::{self, RunStyle},
+    AppContext, ClipboardItem, Element, ElementBox, Entity, FontCache, ModelHandle,
+    MutableAppContext, RenderContext, Task, TextLayoutCache, View, ViewContext, WeakViewHandle,
 };
 use postage::watch;
 use serde::{Deserialize, Serialize};
@@ -2330,7 +2335,7 @@ impl Snapshot {
 
     pub fn line_height(&self, font_cache: &FontCache) -> f32 {
         let font_id = font_cache.default_font(self.font_family);
-        font_cache.line_height(font_id, self.font_size)
+        font_cache.line_height(font_id, self.font_size).ceil()
     }
 
     pub fn em_width(&self, font_cache: &FontCache) -> f32 {
@@ -2355,7 +2360,14 @@ impl Snapshot {
             .layout_str(
                 "1".repeat(digit_count).as_str(),
                 font_size,
-                &[(digit_count, font_id, Color::black())],
+                &[(
+                    digit_count,
+                    RunStyle {
+                        font_id,
+                        color: Color::black(),
+                        underline: false,
+                    },
+                )],
             )
             .width())
     }
@@ -2392,7 +2404,14 @@ impl Snapshot {
                 layouts.push(Some(layout_cache.layout_str(
                     &line_number,
                     self.font_size,
-                    &[(line_number.len(), font_id, color)],
+                    &[(
+                        line_number.len(),
+                        RunStyle {
+                            font_id,
+                            color,
+                            underline: false,
+                        },
+                    )],
                 )));
             }
         }
@@ -2429,7 +2448,14 @@ impl Snapshot {
                     layout_cache.layout_str(
                         line,
                         self.font_size,
-                        &[(line.len(), font_id, style.placeholder_text.color)],
+                        &[(
+                            line.len(),
+                            RunStyle {
+                                font_id,
+                                color: style.placeholder_text.color,
+                                underline: false,
+                            },
+                        )],
                     )
                 })
                 .collect());
@@ -2485,7 +2511,14 @@ impl Snapshot {
                     }
 
                     line.push_str(line_chunk);
-                    styles.push((line_chunk.len(), font_id, style.color));
+                    styles.push((
+                        line_chunk.len(),
+                        RunStyle {
+                            font_id,
+                            color: style.color,
+                            underline: style.underline,
+                        },
+                    ));
                     prev_font_id = font_id;
                     prev_font_properties = style.font_properties;
                 }
@@ -2518,8 +2551,11 @@ impl Snapshot {
             self.font_size,
             &[(
                 self.display_snapshot.line_len(row) as usize,
-                font_id,
-                Color::black(),
+                RunStyle {
+                    font_id,
+                    color: Color::black(),
+                    underline: false,
+                },
             )],
         ))
     }

zed/src/theme.rs πŸ”—

@@ -180,10 +180,12 @@ impl Default for EditorStyle {
             text: HighlightStyle {
                 color: Color::from_u32(0xff0000ff),
                 font_properties: Default::default(),
+                underline: false,
             },
             placeholder_text: HighlightStyle {
                 color: Color::from_u32(0x00ff00ff),
                 font_properties: Default::default(),
+                underline: false,
             },
             background: Default::default(),
             gutter_background: Default::default(),