re-unify markdown parsing between hover_popover and chat

Mikayla created

Change summary

Cargo.lock                         |  39 ++-
Cargo.toml                         |   2 
crates/collab_ui/Cargo.toml        |   2 
crates/collab_ui/src/chat_panel.rs |  26 +-
crates/editor/Cargo.toml           |   1 
crates/editor/src/hover_popover.rs | 305 ++++---------------------------
crates/rich_text/Cargo.toml        |   4 
crates/rich_text/src/rich_text.rs  | 202 +++++++-------------
8 files changed, 155 insertions(+), 426 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1558,12 +1558,12 @@ dependencies = [
  "gpui",
  "language",
  "log",
- "markdown_element",
  "menu",
  "picker",
  "postage",
  "project",
  "recent_projects",
+ "rich_text",
  "schemars",
  "serde",
  "serde_derive",
@@ -2406,6 +2406,7 @@ dependencies = [
  "project",
  "pulldown-cmark",
  "rand 0.8.5",
+ "rich_text",
  "rpc",
  "schemars",
  "serde",
@@ -4324,24 +4325,6 @@ dependencies = [
  "libc",
 ]
 
-[[package]]
-name = "markdown_element"
-version = "0.1.0"
-dependencies = [
- "anyhow",
- "collections",
- "futures 0.3.28",
- "gpui",
- "language",
- "lazy_static",
- "pulldown-cmark",
- "smallvec",
- "smol",
- "sum_tree",
- "theme",
- "util",
-]
-
 [[package]]
 name = "matchers"
 version = "0.1.0"
@@ -6261,6 +6244,24 @@ dependencies = [
  "bytemuck",
 ]
 
+[[package]]
+name = "rich_text"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "collections",
+ "futures 0.3.28",
+ "gpui",
+ "language",
+ "lazy_static",
+ "pulldown-cmark",
+ "smallvec",
+ "smol",
+ "sum_tree",
+ "theme",
+ "util",
+]
+
 [[package]]
 name = "ring"
 version = "0.16.20"

Cargo.toml 🔗

@@ -46,7 +46,6 @@ members = [
     "crates/lsp",
     "crates/media",
     "crates/menu",
-    "crates/markdown_element",
     "crates/node_runtime",
     "crates/outline",
     "crates/picker",
@@ -65,6 +64,7 @@ members = [
     "crates/sqlez",
     "crates/sqlez_macros",
     "crates/feature_flags",
+    "crates/rich_text",
     "crates/storybook",
     "crates/sum_tree",
     "crates/terminal",

crates/collab_ui/Cargo.toml 🔗

@@ -37,7 +37,7 @@ fuzzy = { path = "../fuzzy" }
 gpui = { path = "../gpui" }
 language = { path = "../language" }
 menu = { path = "../menu" }
-markdown_element = { path = "../markdown_element" }
+rich_text = { path = "../rich_text" }
 picker = { path = "../picker" }
 project = { path = "../project" }
 recent_projects = {path = "../recent_projects"}

crates/collab_ui/src/chat_panel.rs 🔗

@@ -17,9 +17,9 @@ use gpui::{
     View, ViewContext, ViewHandle, WeakViewHandle,
 };
 use language::{language_settings::SoftWrap, LanguageRegistry};
-use markdown_element::{MarkdownData, MarkdownElement};
 use menu::Confirm;
 use project::Fs;
+use rich_text::RichText;
 use serde::{Deserialize, Serialize};
 use settings::SettingsStore;
 use std::sync::Arc;
@@ -50,7 +50,7 @@ pub struct ChatPanel {
     subscriptions: Vec<gpui::Subscription>,
     workspace: WeakViewHandle<Workspace>,
     has_focus: bool,
-    markdown_data: HashMap<ChannelMessageId, Arc<MarkdownData>>,
+    markdown_data: HashMap<ChannelMessageId, RichText>,
 }
 
 #[derive(Serialize, Deserialize)]
@@ -370,12 +370,10 @@ impl ChatPanel {
         };
 
         let is_pending = message.is_pending();
-        let markdown = self.markdown_data.entry(message.id).or_insert_with(|| {
-            Arc::new(markdown_element::render_markdown(
-                message.body,
-                &self.languages,
-            ))
-        });
+        let text = self
+            .markdown_data
+            .entry(message.id)
+            .or_insert_with(|| rich_text::render_markdown(message.body, &self.languages, None));
 
         let now = OffsetDateTime::now_utc();
         let theme = theme::current(cx);
@@ -401,11 +399,11 @@ impl ChatPanel {
             if is_continuation {
                 Flex::row()
                     .with_child(
-                        MarkdownElement::new(
-                            markdown.clone(),
-                            style.body.clone(),
+                        text.element(
                             theme.editor.syntax.clone(),
+                            style.body.clone(),
                             theme.editor.document_highlight_read_background,
+                            cx,
                         )
                         .flex(1., true),
                     )
@@ -457,11 +455,11 @@ impl ChatPanel {
                     .with_child(
                         Flex::row()
                             .with_child(
-                                MarkdownElement::new(
-                                    markdown.clone(),
-                                    style.body.clone(),
+                                text.element(
                                     theme.editor.syntax.clone(),
+                                    style.body.clone(),
                                     theme.editor.document_highlight_read_background,
+                                    cx,
                                 )
                                 .flex(1., true),
                             )

crates/editor/Cargo.toml 🔗

@@ -36,6 +36,7 @@ language = { path = "../language" }
 lsp = { path = "../lsp" }
 project = { path = "../project" }
 rpc = { path = "../rpc" }
+rich_text = { path = "../rich_text" }
 settings = { path = "../settings" }
 snippet = { path = "../snippet" }
 sum_tree = { path = "../sum_tree" }

crates/editor/src/hover_popover.rs 🔗

@@ -8,12 +8,12 @@ use futures::FutureExt;
 use gpui::{
     actions,
     elements::{Flex, MouseEventHandler, Padding, ParentElement, Text},
-    fonts::{HighlightStyle, Underline, Weight},
     platform::{CursorStyle, MouseButton},
-    AnyElement, AppContext, CursorRegion, Element, ModelHandle, MouseRegion, Task, ViewContext,
+    AnyElement, AppContext, Element, ModelHandle, Task, ViewContext,
 };
 use language::{Bias, DiagnosticEntry, DiagnosticSeverity, Language, LanguageRegistry};
 use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart, Project};
+use rich_text::{new_paragraph, render_code, render_markdown_mut, RichText};
 use std::{ops::Range, sync::Arc, time::Duration};
 use util::TryFutureExt;
 
@@ -346,158 +346,25 @@ fn show_hover(
 }
 
 fn render_blocks(
-    theme_id: usize,
     blocks: &[HoverBlock],
     language_registry: &Arc<LanguageRegistry>,
     language: Option<&Arc<Language>>,
-    style: &EditorStyle,
-) -> RenderedInfo {
-    let mut text = String::new();
-    let mut highlights = Vec::new();
-    let mut region_ranges = Vec::new();
-    let mut regions = Vec::new();
+) -> RichText {
+    let mut data = RichText {
+        text: Default::default(),
+        highlights: Default::default(),
+        region_ranges: Default::default(),
+        regions: Default::default(),
+    };
 
     for block in blocks {
         match &block.kind {
             HoverBlockKind::PlainText => {
-                new_paragraph(&mut text, &mut Vec::new());
-                text.push_str(&block.text);
+                new_paragraph(&mut data.text, &mut Vec::new());
+                data.text.push_str(&block.text);
             }
             HoverBlockKind::Markdown => {
-                use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag};
-
-                let mut bold_depth = 0;
-                let mut italic_depth = 0;
-                let mut link_url = None;
-                let mut current_language = None;
-                let mut list_stack = Vec::new();
-
-                for event in Parser::new_ext(&block.text, Options::all()) {
-                    let prev_len = text.len();
-                    match event {
-                        Event::Text(t) => {
-                            if let Some(language) = &current_language {
-                                render_code(
-                                    &mut text,
-                                    &mut highlights,
-                                    t.as_ref(),
-                                    language,
-                                    style,
-                                );
-                            } else {
-                                text.push_str(t.as_ref());
-
-                                let mut style = HighlightStyle::default();
-                                if bold_depth > 0 {
-                                    style.weight = Some(Weight::BOLD);
-                                }
-                                if italic_depth > 0 {
-                                    style.italic = Some(true);
-                                }
-                                if let Some(link_url) = link_url.clone() {
-                                    region_ranges.push(prev_len..text.len());
-                                    regions.push(RenderedRegion {
-                                        link_url: Some(link_url),
-                                        code: false,
-                                    });
-                                    style.underline = Some(Underline {
-                                        thickness: 1.0.into(),
-                                        ..Default::default()
-                                    });
-                                }
-
-                                if style != HighlightStyle::default() {
-                                    let mut new_highlight = true;
-                                    if let Some((last_range, last_style)) = highlights.last_mut() {
-                                        if last_range.end == prev_len && last_style == &style {
-                                            last_range.end = text.len();
-                                            new_highlight = false;
-                                        }
-                                    }
-                                    if new_highlight {
-                                        highlights.push((prev_len..text.len(), style));
-                                    }
-                                }
-                            }
-                        }
-                        Event::Code(t) => {
-                            text.push_str(t.as_ref());
-                            region_ranges.push(prev_len..text.len());
-                            if link_url.is_some() {
-                                highlights.push((
-                                    prev_len..text.len(),
-                                    HighlightStyle {
-                                        underline: Some(Underline {
-                                            thickness: 1.0.into(),
-                                            ..Default::default()
-                                        }),
-                                        ..Default::default()
-                                    },
-                                ));
-                            }
-                            regions.push(RenderedRegion {
-                                code: true,
-                                link_url: link_url.clone(),
-                            });
-                        }
-                        Event::Start(tag) => match tag {
-                            Tag::Paragraph => new_paragraph(&mut text, &mut list_stack),
-                            Tag::Heading(_, _, _) => {
-                                new_paragraph(&mut text, &mut list_stack);
-                                bold_depth += 1;
-                            }
-                            Tag::CodeBlock(kind) => {
-                                new_paragraph(&mut text, &mut list_stack);
-                                current_language = if let CodeBlockKind::Fenced(language) = kind {
-                                    language_registry
-                                        .language_for_name(language.as_ref())
-                                        .now_or_never()
-                                        .and_then(Result::ok)
-                                } else {
-                                    language.cloned()
-                                }
-                            }
-                            Tag::Emphasis => italic_depth += 1,
-                            Tag::Strong => bold_depth += 1,
-                            Tag::Link(_, url, _) => link_url = Some(url.to_string()),
-                            Tag::List(number) => {
-                                list_stack.push((number, false));
-                            }
-                            Tag::Item => {
-                                let len = list_stack.len();
-                                if let Some((list_number, has_content)) = list_stack.last_mut() {
-                                    *has_content = false;
-                                    if !text.is_empty() && !text.ends_with('\n') {
-                                        text.push('\n');
-                                    }
-                                    for _ in 0..len - 1 {
-                                        text.push_str("  ");
-                                    }
-                                    if let Some(number) = list_number {
-                                        text.push_str(&format!("{}. ", number));
-                                        *number += 1;
-                                        *has_content = false;
-                                    } else {
-                                        text.push_str("- ");
-                                    }
-                                }
-                            }
-                            _ => {}
-                        },
-                        Event::End(tag) => match tag {
-                            Tag::Heading(_, _, _) => bold_depth -= 1,
-                            Tag::CodeBlock(_) => current_language = None,
-                            Tag::Emphasis => italic_depth -= 1,
-                            Tag::Strong => bold_depth -= 1,
-                            Tag::Link(_, _, _) => link_url = None,
-                            Tag::List(_) => drop(list_stack.pop()),
-                            _ => {}
-                        },
-                        Event::HardBreak => text.push('\n'),
-                        Event::SoftBreak => text.push(' '),
-                        _ => {}
-                    }
-                }
+                render_markdown_mut(&block.text, language_registry, language, &mut data)
             }
             HoverBlockKind::Code { language } => {
                 if let Some(language) = language_registry
@@ -505,62 +372,17 @@ fn render_blocks(
                     .now_or_never()
                     .and_then(Result::ok)
                 {
-                    render_code(&mut text, &mut highlights, &block.text, &language, style);
+                    render_code(&mut data.text, &mut data.highlights, &block.text, &language);
                 } else {
-                    text.push_str(&block.text);
+                    data.text.push_str(&block.text);
                 }
             }
         }
     }
 
-    RenderedInfo {
-        theme_id,
-        text: text.trim().to_string(),
-        highlights,
-        region_ranges,
-        regions,
-    }
-}
-
-fn render_code(
-    text: &mut String,
-    highlights: &mut Vec<(Range<usize>, HighlightStyle)>,
-    content: &str,
-    language: &Arc<Language>,
-    style: &EditorStyle,
-) {
-    let prev_len = text.len();
-    text.push_str(content);
-    for (range, highlight_id) in language.highlight_text(&content.into(), 0..content.len()) {
-        if let Some(style) = highlight_id.style(&style.syntax) {
-            highlights.push((prev_len + range.start..prev_len + range.end, style));
-        }
-    }
-}
-
-fn new_paragraph(text: &mut String, list_stack: &mut Vec<(Option<u64>, bool)>) {
-    let mut is_subsequent_paragraph_of_list = false;
-    if let Some((_, has_content)) = list_stack.last_mut() {
-        if *has_content {
-            is_subsequent_paragraph_of_list = true;
-        } else {
-            *has_content = true;
-            return;
-        }
-    }
+    data.text = data.text.trim().to_string();
 
-    if !text.is_empty() {
-        if !text.ends_with('\n') {
-            text.push('\n');
-        }
-        text.push('\n');
-    }
-    for _ in 0..list_stack.len().saturating_sub(1) {
-        text.push_str("  ");
-    }
-    if is_subsequent_paragraph_of_list {
-        text.push_str("  ");
-    }
+    data
 }
 
 #[derive(Default)]
@@ -623,22 +445,7 @@ pub struct InfoPopover {
     symbol_range: RangeInEditor,
     pub blocks: Vec<HoverBlock>,
     language: Option<Arc<Language>>,
-    rendered_content: Option<RenderedInfo>,
-}
-
-#[derive(Debug, Clone)]
-struct RenderedInfo {
-    theme_id: usize,
-    text: String,
-    highlights: Vec<(Range<usize>, HighlightStyle)>,
-    region_ranges: Vec<Range<usize>>,
-    regions: Vec<RenderedRegion>,
-}
-
-#[derive(Debug, Clone)]
-struct RenderedRegion {
-    code: bool,
-    link_url: Option<String>,
+    rendered_content: Option<RichText>,
 }
 
 impl InfoPopover {
@@ -647,63 +454,24 @@ impl InfoPopover {
         style: &EditorStyle,
         cx: &mut ViewContext<Editor>,
     ) -> AnyElement<Editor> {
-        if let Some(rendered) = &self.rendered_content {
-            if rendered.theme_id != style.theme_id {
-                self.rendered_content = None;
-            }
-        }
-
         let rendered_content = self.rendered_content.get_or_insert_with(|| {
             render_blocks(
-                style.theme_id,
                 &self.blocks,
                 self.project.read(cx).languages(),
                 self.language.as_ref(),
-                style,
             )
         });
 
-        MouseEventHandler::new::<InfoPopover, _>(0, cx, |_, cx| {
-            let mut region_id = 0;
-            let view_id = cx.view_id();
-
+        MouseEventHandler::new::<InfoPopover, _>(0, cx, move |_, cx| {
             let code_span_background_color = style.document_highlight_read_background;
-            let regions = rendered_content.regions.clone();
             Flex::column()
                 .scrollable::<HoverBlock>(1, None, cx)
-                .with_child(
-                    Text::new(rendered_content.text.clone(), style.text.clone())
-                        .with_highlights(rendered_content.highlights.clone())
-                        .with_custom_runs(
-                            rendered_content.region_ranges.clone(),
-                            move |ix, bounds, cx| {
-                                region_id += 1;
-                                let region = regions[ix].clone();
-                                if let Some(url) = region.link_url {
-                                    cx.scene().push_cursor_region(CursorRegion {
-                                        bounds,
-                                        style: CursorStyle::PointingHand,
-                                    });
-                                    cx.scene().push_mouse_region(
-                                        MouseRegion::new::<Self>(view_id, region_id, bounds)
-                                            .on_click::<Editor, _>(
-                                                MouseButton::Left,
-                                                move |_, _, cx| cx.platform().open_url(&url),
-                                            ),
-                                    );
-                                }
-                                if region.code {
-                                    cx.scene().push_quad(gpui::Quad {
-                                        bounds,
-                                        background: Some(code_span_background_color),
-                                        border: Default::default(),
-                                        corner_radii: (2.0).into(),
-                                    });
-                                }
-                            },
-                        )
-                        .with_soft_wrap(true),
-                )
+                .with_child(rendered_content.element(
+                    style.syntax.clone(),
+                    style.text.clone(),
+                    code_span_background_color,
+                    cx,
+                ))
                 .contained()
                 .with_style(style.hover_popover.container)
         })
@@ -799,11 +567,12 @@ mod tests {
         InlayId,
     };
     use collections::BTreeSet;
-    use gpui::fonts::Weight;
+    use gpui::fonts::{HighlightStyle, Underline, Weight};
     use indoc::indoc;
     use language::{language_settings::InlayHintSettings, Diagnostic, DiagnosticSet};
     use lsp::LanguageServerId;
     use project::{HoverBlock, HoverBlockKind};
+    use rich_text::Highlight;
     use smol::stream::StreamExt;
     use unindent::Unindent;
     use util::test::marked_text_ranges;
@@ -1014,7 +783,7 @@ mod tests {
         .await;
 
         cx.condition(|editor, _| editor.hover_state.visible()).await;
-        cx.editor(|editor, cx| {
+        cx.editor(|editor, _| {
             let blocks = editor.hover_state.info_popover.clone().unwrap().blocks;
             assert_eq!(
                 blocks,
@@ -1024,8 +793,7 @@ mod tests {
                 }],
             );
 
-            let style = editor.style(cx);
-            let rendered = render_blocks(0, &blocks, &Default::default(), None, &style);
+            let rendered = render_blocks(&blocks, &Default::default(), None);
             assert_eq!(
                 rendered.text,
                 code_str.trim(),
@@ -1217,7 +985,7 @@ mod tests {
                 expected_styles,
             } in &rows[0..]
             {
-                let rendered = render_blocks(0, &blocks, &Default::default(), None, &style);
+                let rendered = render_blocks(&blocks, &Default::default(), None);
 
                 let (expected_text, ranges) = marked_text_ranges(expected_marked_text, false);
                 let expected_highlights = ranges
@@ -1228,8 +996,21 @@ mod tests {
                     rendered.text, expected_text,
                     "wrong text for input {blocks:?}"
                 );
+
+                let rendered_highlights: Vec<_> = rendered
+                    .highlights
+                    .iter()
+                    .filter_map(|(range, highlight)| {
+                        let style = match highlight {
+                            Highlight::Id(id) => id.style(&style.syntax)?,
+                            Highlight::Highlight(style) => style.clone(),
+                        };
+                        Some((range.clone(), style))
+                    })
+                    .collect();
+
                 assert_eq!(
-                    rendered.highlights, expected_highlights,
+                    rendered_highlights, expected_highlights,
                     "wrong highlights for input {blocks:?}"
                 );
             }

crates/markdown_element/Cargo.toml → crates/rich_text/Cargo.toml 🔗

@@ -1,11 +1,11 @@
 [package]
-name = "markdown_element"
+name = "rich_text"
 version = "0.1.0"
 edition = "2021"
 publish = false
 
 [lib]
-path = "src/markdown_element.rs"
+path = "src/rich_text.rs"
 doctest = false
 
 [features]

crates/markdown_element/src/markdown_element.rs → crates/rich_text/src/rich_text.rs 🔗

@@ -6,94 +6,68 @@ use gpui::{
     elements::Text,
     fonts::{HighlightStyle, TextStyle, Underline, Weight},
     platform::{CursorStyle, MouseButton},
-    AnyElement, CursorRegion, Element, MouseRegion,
+    AnyElement, CursorRegion, Element, MouseRegion, ViewContext,
 };
 use language::{HighlightId, Language, LanguageRegistry};
 use theme::SyntaxTheme;
 
 #[derive(Debug, Clone, PartialEq, Eq)]
-enum Highlight {
+pub enum Highlight {
     Id(HighlightId),
     Highlight(HighlightStyle),
 }
 
 #[derive(Debug, Clone)]
-pub struct MarkdownData {
-    text: String,
-    highlights: Vec<(Range<usize>, Highlight)>,
-    region_ranges: Vec<Range<usize>>,
-    regions: Vec<RenderedRegion>,
+pub struct RichText {
+    pub text: String,
+    pub highlights: Vec<(Range<usize>, Highlight)>,
+    pub region_ranges: Vec<Range<usize>>,
+    pub regions: Vec<RenderedRegion>,
 }
 
 #[derive(Debug, Clone)]
-struct RenderedRegion {
+pub struct RenderedRegion {
     code: bool,
     link_url: Option<String>,
 }
 
-pub struct MarkdownElement {
-    data: Arc<MarkdownData>,
-    syntax: Arc<SyntaxTheme>,
-    style: TextStyle,
-    code_span_background_color: Color,
-}
-
-impl MarkdownElement {
-    pub fn new(
-        data: Arc<MarkdownData>,
-        style: TextStyle,
+impl RichText {
+    pub fn element<V: 'static>(
+        &self,
         syntax: Arc<SyntaxTheme>,
+        style: TextStyle,
         code_span_background_color: Color,
-    ) -> Self {
-        Self {
-            data,
-            style,
-            syntax,
-            code_span_background_color,
-        }
-    }
-}
-
-impl<V: 'static> Element<V> for MarkdownElement {
-    type LayoutState = AnyElement<V>;
-
-    type PaintState = ();
-
-    fn layout(
-        &mut self,
-        constraint: gpui::SizeConstraint,
-        view: &mut V,
-        cx: &mut gpui::ViewContext<V>,
-    ) -> (gpui::geometry::vector::Vector2F, Self::LayoutState) {
+        cx: &mut ViewContext<V>,
+    ) -> AnyElement<V> {
         let mut region_id = 0;
         let view_id = cx.view_id();
 
-        let code_span_background_color = self.code_span_background_color;
-        let data = self.data.clone();
-        let mut element = Text::new(self.data.text.clone(), self.style.clone())
+        let regions = self.regions.clone();
+
+        enum Markdown {}
+        Text::new(self.text.clone(), style.clone())
             .with_highlights(
-                self.data
-                    .highlights
+                self.highlights
                     .iter()
                     .filter_map(|(range, highlight)| {
                         let style = match highlight {
-                            Highlight::Id(id) => id.style(&self.syntax)?,
+                            Highlight::Id(id) => id.style(&syntax)?,
                             Highlight::Highlight(style) => style.clone(),
                         };
                         Some((range.clone(), style))
                     })
                     .collect::<Vec<_>>(),
             )
-            .with_custom_runs(self.data.region_ranges.clone(), move |ix, bounds, cx| {
+            .with_custom_runs(self.region_ranges.clone(), move |ix, bounds, cx| {
                 region_id += 1;
-                let region = data.regions[ix].clone();
+                let region = regions[ix].clone();
                 if let Some(url) = region.link_url {
                     cx.scene().push_cursor_region(CursorRegion {
                         bounds,
                         style: CursorStyle::PointingHand,
                     });
                     cx.scene().push_mouse_region(
-                        MouseRegion::new::<Self>(view_id, region_id, bounds)
+                        MouseRegion::new::<Markdown>(view_id, region_id, bounds)
                             .on_click::<V, _>(MouseButton::Left, move |_, _, cx| {
                                 cx.platform().open_url(&url)
                             }),
@@ -109,55 +83,16 @@ impl<V: 'static> Element<V> for MarkdownElement {
                 }
             })
             .with_soft_wrap(true)
-            .into_any();
-
-        let constraint = element.layout(constraint, view, cx);
-
-        (constraint, element)
-    }
-
-    fn paint(
-        &mut self,
-        bounds: gpui::geometry::rect::RectF,
-        visible_bounds: gpui::geometry::rect::RectF,
-        layout: &mut Self::LayoutState,
-        view: &mut V,
-        cx: &mut gpui::ViewContext<V>,
-    ) -> Self::PaintState {
-        layout.paint(bounds.origin(), visible_bounds, view, cx);
-    }
-
-    fn rect_for_text_range(
-        &self,
-        range_utf16: std::ops::Range<usize>,
-        _: gpui::geometry::rect::RectF,
-        _: gpui::geometry::rect::RectF,
-        layout: &Self::LayoutState,
-        _: &Self::PaintState,
-        view: &V,
-        cx: &gpui::ViewContext<V>,
-    ) -> Option<gpui::geometry::rect::RectF> {
-        layout.rect_for_text_range(range_utf16, view, cx)
-    }
-
-    fn debug(
-        &self,
-        _: gpui::geometry::rect::RectF,
-        layout: &Self::LayoutState,
-        _: &Self::PaintState,
-        view: &V,
-        cx: &gpui::ViewContext<V>,
-    ) -> gpui::serde_json::Value {
-        layout.debug(view, cx)
+            .into_any()
     }
 }
 
-pub fn render_markdown(block: String, language_registry: &Arc<LanguageRegistry>) -> MarkdownData {
-    let mut text = String::new();
-    let mut highlights = Vec::new();
-    let mut region_ranges = Vec::new();
-    let mut regions = Vec::new();
-
+pub fn render_markdown_mut(
+    block: &str,
+    language_registry: &Arc<LanguageRegistry>,
+    language: Option<&Arc<Language>>,
+    data: &mut RichText,
+) {
     use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag};
 
     let mut bold_depth = 0;
@@ -167,13 +102,13 @@ pub fn render_markdown(block: String, language_registry: &Arc<LanguageRegistry>)
     let mut list_stack = Vec::new();
 
     for event in Parser::new_ext(&block, Options::all()) {
-        let prev_len = text.len();
+        let prev_len = data.text.len();
         match event {
             Event::Text(t) => {
                 if let Some(language) = &current_language {
-                    render_code(&mut text, &mut highlights, t.as_ref(), language);
+                    render_code(&mut data.text, &mut data.highlights, t.as_ref(), language);
                 } else {
-                    text.push_str(t.as_ref());
+                    data.text.push_str(t.as_ref());
 
                     let mut style = HighlightStyle::default();
                     if bold_depth > 0 {
@@ -183,8 +118,8 @@ pub fn render_markdown(block: String, language_registry: &Arc<LanguageRegistry>)
                         style.italic = Some(true);
                     }
                     if let Some(link_url) = link_url.clone() {
-                        region_ranges.push(prev_len..text.len());
-                        regions.push(RenderedRegion {
+                        data.region_ranges.push(prev_len..data.text.len());
+                        data.regions.push(RenderedRegion {
                             link_url: Some(link_url),
                             code: false,
                         });
@@ -196,26 +131,27 @@ pub fn render_markdown(block: String, language_registry: &Arc<LanguageRegistry>)
 
                     if style != HighlightStyle::default() {
                         let mut new_highlight = true;
-                        if let Some((last_range, last_style)) = highlights.last_mut() {
+                        if let Some((last_range, last_style)) = data.highlights.last_mut() {
                             if last_range.end == prev_len
                                 && last_style == &Highlight::Highlight(style)
                             {
-                                last_range.end = text.len();
+                                last_range.end = data.text.len();
                                 new_highlight = false;
                             }
                         }
                         if new_highlight {
-                            highlights.push((prev_len..text.len(), Highlight::Highlight(style)));
+                            data.highlights
+                                .push((prev_len..data.text.len(), Highlight::Highlight(style)));
                         }
                     }
                 }
             }
             Event::Code(t) => {
-                text.push_str(t.as_ref());
-                region_ranges.push(prev_len..text.len());
+                data.text.push_str(t.as_ref());
+                data.region_ranges.push(prev_len..data.text.len());
                 if link_url.is_some() {
-                    highlights.push((
-                        prev_len..text.len(),
+                    data.highlights.push((
+                        prev_len..data.text.len(),
                         Highlight::Highlight(HighlightStyle {
                             underline: Some(Underline {
                                 thickness: 1.0.into(),
@@ -225,26 +161,26 @@ pub fn render_markdown(block: String, language_registry: &Arc<LanguageRegistry>)
                         }),
                     ));
                 }
-                regions.push(RenderedRegion {
+                data.regions.push(RenderedRegion {
                     code: true,
                     link_url: link_url.clone(),
                 });
             }
             Event::Start(tag) => match tag {
-                Tag::Paragraph => new_paragraph(&mut text, &mut list_stack),
+                Tag::Paragraph => new_paragraph(&mut data.text, &mut list_stack),
                 Tag::Heading(_, _, _) => {
-                    new_paragraph(&mut text, &mut list_stack);
+                    new_paragraph(&mut data.text, &mut list_stack);
                     bold_depth += 1;
                 }
                 Tag::CodeBlock(kind) => {
-                    new_paragraph(&mut text, &mut list_stack);
+                    new_paragraph(&mut data.text, &mut list_stack);
                     current_language = if let CodeBlockKind::Fenced(language) = kind {
                         language_registry
                             .language_for_name(language.as_ref())
                             .now_or_never()
                             .and_then(Result::ok)
                     } else {
-                        None
+                        language.cloned()
                     }
                 }
                 Tag::Emphasis => italic_depth += 1,
@@ -257,18 +193,18 @@ pub fn render_markdown(block: String, language_registry: &Arc<LanguageRegistry>)
                     let len = list_stack.len();
                     if let Some((list_number, has_content)) = list_stack.last_mut() {
                         *has_content = false;
-                        if !text.is_empty() && !text.ends_with('\n') {
-                            text.push('\n');
+                        if !data.text.is_empty() && !data.text.ends_with('\n') {
+                            data.text.push('\n');
                         }
                         for _ in 0..len - 1 {
-                            text.push_str("  ");
+                            data.text.push_str("  ");
                         }
                         if let Some(number) = list_number {
-                            text.push_str(&format!("{}. ", number));
+                            data.text.push_str(&format!("{}. ", number));
                             *number += 1;
                             *has_content = false;
                         } else {
-                            text.push_str("- ");
+                            data.text.push_str("- ");
                         }
                     }
                 }
@@ -283,21 +219,33 @@ pub fn render_markdown(block: String, language_registry: &Arc<LanguageRegistry>)
                 Tag::List(_) => drop(list_stack.pop()),
                 _ => {}
             },
-            Event::HardBreak => text.push('\n'),
-            Event::SoftBreak => text.push(' '),
+            Event::HardBreak => data.text.push('\n'),
+            Event::SoftBreak => data.text.push(' '),
             _ => {}
         }
     }
+}
 
-    MarkdownData {
-        text: text.trim().to_string(),
-        highlights,
-        region_ranges,
-        regions,
-    }
+pub fn render_markdown(
+    block: String,
+    language_registry: &Arc<LanguageRegistry>,
+    language: Option<&Arc<Language>>,
+) -> RichText {
+    let mut data = RichText {
+        text: Default::default(),
+        highlights: Default::default(),
+        region_ranges: Default::default(),
+        regions: Default::default(),
+    };
+
+    render_markdown_mut(&block, language_registry, language, &mut data);
+
+    data.text = data.text.trim().to_string();
+
+    data
 }
 
-fn render_code(
+pub fn render_code(
     text: &mut String,
     highlights: &mut Vec<(Range<usize>, Highlight)>,
     content: &str,
@@ -313,7 +261,7 @@ fn render_code(
     }
 }
 
-fn new_paragraph(text: &mut String, list_stack: &mut Vec<(Option<u64>, bool)>) {
+pub fn new_paragraph(text: &mut String, list_stack: &mut Vec<(Option<u64>, bool)>) {
     let mut is_subsequent_paragraph_of_list = false;
     if let Some((_, has_content)) = list_stack.last_mut() {
         if *has_content {