Add markdown parsing to channel chat (#3088)

Mikayla Maki created

TODO:
- [x] Add markdown rendering to channel chat
- [x] Unify (?) rendering logic between hover popover and chat
- [x] ~~Determine how to deal with document-oriented markdown like `#`~~
Unimportant until we want to do something special with `#channel`
- [x] Tidy up spacing and styles in chat panel

Release Notes:

- Added markdown rendering to channel chat
- Improved channel chat message style
- Fixed a bug where long chat messages would not soft wrap

Change summary

Cargo.lock                           |  20 +
Cargo.toml                           |   1 
crates/channel/src/channel_chat.rs   |   2 
crates/collab_ui/Cargo.toml          |   1 
crates/collab_ui/src/chat_panel.rs   | 287 ++++++++++++++++++----------
crates/collab_ui/src/collab_panel.rs |   6 
crates/editor/Cargo.toml             |   1 
crates/editor/src/hover_popover.rs   | 305 ++++-------------------------
crates/rich_text/Cargo.toml          |  30 ++
crates/rich_text/src/rich_text.rs    | 287 ++++++++++++++++++++++++++++
crates/theme/src/theme.rs            |   4 
styles/src/style_tree/chat_panel.ts  |  69 ++++++
12 files changed, 636 insertions(+), 377 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1563,6 +1563,7 @@ dependencies = [
  "postage",
  "project",
  "recent_projects",
+ "rich_text",
  "schemars",
  "serde",
  "serde_derive",
@@ -2405,6 +2406,7 @@ dependencies = [
  "project",
  "pulldown-cmark",
  "rand 0.8.5",
+ "rich_text",
  "rpc",
  "schemars",
  "serde",
@@ -6242,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 🔗

@@ -64,6 +64,7 @@ members = [
     "crates/sqlez",
     "crates/sqlez_macros",
     "crates/feature_flags",
+    "crates/rich_text",
     "crates/storybook",
     "crates/sum_tree",
     "crates/terminal",

crates/channel/src/channel_chat.rs 🔗

@@ -36,7 +36,7 @@ pub struct ChannelMessage {
     pub nonce: u128,
 }
 
-#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
 pub enum ChannelMessageId {
     Saved(u64),
     Pending(usize),

crates/collab_ui/Cargo.toml 🔗

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

crates/collab_ui/src/chat_panel.rs 🔗

@@ -3,6 +3,7 @@ use anyhow::Result;
 use call::ActiveCall;
 use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore};
 use client::Client;
+use collections::HashMap;
 use db::kvp::KEY_VALUE_STORE;
 use editor::Editor;
 use feature_flags::{ChannelsAlpha, FeatureFlagAppExt};
@@ -12,12 +13,13 @@ use gpui::{
     platform::{CursorStyle, MouseButton},
     serde_json,
     views::{ItemType, Select, SelectStyle},
-    AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Subscription, Task, View,
-    ViewContext, ViewHandle, WeakViewHandle,
+    AnyViewHandle, AppContext, AsyncAppContext, Entity, ImageData, ModelHandle, Subscription, Task,
+    View, ViewContext, ViewHandle, WeakViewHandle,
 };
-use language::language_settings::SoftWrap;
+use language::{language_settings::SoftWrap, LanguageRegistry};
 use menu::Confirm;
 use project::Fs;
+use rich_text::RichText;
 use serde::{Deserialize, Serialize};
 use settings::SettingsStore;
 use std::sync::Arc;
@@ -35,6 +37,7 @@ const CHAT_PANEL_KEY: &'static str = "ChatPanel";
 pub struct ChatPanel {
     client: Arc<Client>,
     channel_store: ModelHandle<ChannelStore>,
+    languages: Arc<LanguageRegistry>,
     active_chat: Option<(ModelHandle<ChannelChat>, Subscription)>,
     message_list: ListState<ChatPanel>,
     input_editor: ViewHandle<Editor>,
@@ -47,6 +50,7 @@ pub struct ChatPanel {
     subscriptions: Vec<gpui::Subscription>,
     workspace: WeakViewHandle<Workspace>,
     has_focus: bool,
+    markdown_data: HashMap<ChannelMessageId, RichText>,
 }
 
 #[derive(Serialize, Deserialize)]
@@ -78,6 +82,7 @@ impl ChatPanel {
         let fs = workspace.app_state().fs.clone();
         let client = workspace.app_state().client.clone();
         let channel_store = workspace.app_state().channel_store.clone();
+        let languages = workspace.app_state().languages.clone();
 
         let input_editor = cx.add_view(|cx| {
             let mut editor = Editor::auto_height(
@@ -130,6 +135,7 @@ impl ChatPanel {
                 fs,
                 client,
                 channel_store,
+                languages,
 
                 active_chat: Default::default(),
                 pending_serialization: Task::ready(None),
@@ -142,6 +148,7 @@ impl ChatPanel {
                 workspace: workspace_handle,
                 active: false,
                 width: None,
+                markdown_data: Default::default(),
             };
 
             let mut old_dock_position = this.position(cx);
@@ -178,6 +185,25 @@ impl ChatPanel {
             })
             .detach();
 
+            let markdown = this.languages.language_for_name("Markdown");
+            cx.spawn(|this, mut cx| async move {
+                let markdown = markdown.await?;
+
+                this.update(&mut cx, |this, cx| {
+                    this.input_editor.update(cx, |editor, cx| {
+                        editor.buffer().update(cx, |multi_buffer, cx| {
+                            multi_buffer
+                                .as_singleton()
+                                .unwrap()
+                                .update(cx, |buffer, cx| buffer.set_language(Some(markdown), cx))
+                        })
+                    })
+                })?;
+
+                anyhow::Ok(())
+            })
+            .detach_and_log_err(cx);
+
             this
         })
     }
@@ -328,7 +354,7 @@ impl ChatPanel {
         messages.flex(1., true).into_any()
     }
 
-    fn render_message(&self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+    fn render_message(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
         let (message, is_continuation, is_last) = {
             let active_chat = self.active_chat.as_ref().unwrap().0.read(cx);
             let last_message = active_chat.message(ix.saturating_sub(1));
@@ -337,15 +363,21 @@ impl ChatPanel {
                 && this_message.sender.id == last_message.sender.id;
 
             (
-                active_chat.message(ix),
+                active_chat.message(ix).clone(),
                 is_continuation,
                 active_chat.message_count() == ix + 1,
             )
         };
 
+        let is_pending = message.is_pending();
+        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);
-        let style = if message.is_pending() {
+        let style = if is_pending {
             &theme.chat_panel.pending_message
         } else if is_continuation {
             &theme.chat_panel.continuation_message
@@ -361,106 +393,90 @@ impl ChatPanel {
                 None
             };
 
-        enum DeleteMessage {}
-
-        let body = message.body.clone();
-        if is_continuation {
-            Flex::row()
-                .with_child(Text::new(body, style.body.clone()))
-                .with_children(message_id_to_remove.map(|id| {
-                    MouseEventHandler::new::<DeleteMessage, _>(id as usize, cx, |mouse_state, _| {
-                        let button_style = theme.chat_panel.icon_button.style_for(mouse_state);
-                        render_icon_button(button_style, "icons/x.svg")
-                            .aligned()
-                            .into_any()
-                    })
-                    .with_padding(Padding::uniform(2.))
-                    .with_cursor_style(CursorStyle::PointingHand)
-                    .on_click(MouseButton::Left, move |_, this, cx| {
-                        this.remove_message(id, cx);
+        enum MessageBackgroundHighlight {}
+        MouseEventHandler::new::<MessageBackgroundHighlight, _>(ix, cx, |state, cx| {
+            let container = style.container.style_for(state);
+            if is_continuation {
+                Flex::row()
+                    .with_child(
+                        text.element(
+                            theme.editor.syntax.clone(),
+                            style.body.clone(),
+                            theme.editor.document_highlight_read_background,
+                            cx,
+                        )
+                        .flex(1., true),
+                    )
+                    .with_child(render_remove(message_id_to_remove, cx, &theme))
+                    .contained()
+                    .with_style(*container)
+                    .with_margin_bottom(if is_last {
+                        theme.chat_panel.last_message_bottom_spacing
+                    } else {
+                        0.
                     })
-                    .flex_float()
-                }))
-                .contained()
-                .with_style(style.container)
-                .with_margin_bottom(if is_last {
-                    theme.chat_panel.last_message_bottom_spacing
-                } else {
-                    0.
-                })
-                .into_any()
-        } else {
-            Flex::column()
-                .with_child(
-                    Flex::row()
-                        .with_child(
-                            message
-                                .sender
-                                .avatar
-                                .clone()
-                                .map(|avatar| {
-                                    Image::from_data(avatar)
-                                        .with_style(theme.collab_panel.channel_avatar)
-                                        .into_any()
-                                })
-                                .unwrap_or_else(|| {
-                                    Empty::new()
-                                        .constrained()
-                                        .with_width(
-                                            theme.collab_panel.channel_avatar.width.unwrap_or(12.),
+                    .into_any()
+            } else {
+                Flex::column()
+                    .with_child(
+                        Flex::row()
+                            .with_child(
+                                Flex::row()
+                                    .with_child(render_avatar(
+                                        message.sender.avatar.clone(),
+                                        &theme,
+                                    ))
+                                    .with_child(
+                                        Label::new(
+                                            message.sender.github_login.clone(),
+                                            style.sender.text.clone(),
                                         )
-                                        .into_any()
-                                })
-                                .contained()
-                                .with_margin_right(4.),
-                        )
-                        .with_child(
-                            Label::new(
-                                message.sender.github_login.clone(),
-                                style.sender.text.clone(),
-                            )
-                            .contained()
-                            .with_style(style.sender.container),
-                        )
-                        .with_child(
-                            Label::new(
-                                format_timestamp(message.timestamp, now, self.local_timezone),
-                                style.timestamp.text.clone(),
+                                        .contained()
+                                        .with_style(style.sender.container),
+                                    )
+                                    .with_child(
+                                        Label::new(
+                                            format_timestamp(
+                                                message.timestamp,
+                                                now,
+                                                self.local_timezone,
+                                            ),
+                                            style.timestamp.text.clone(),
+                                        )
+                                        .contained()
+                                        .with_style(style.timestamp.container),
+                                    )
+                                    .align_children_center()
+                                    .flex(1., true),
                             )
-                            .contained()
-                            .with_style(style.timestamp.container),
-                        )
-                        .with_children(message_id_to_remove.map(|id| {
-                            MouseEventHandler::new::<DeleteMessage, _>(
-                                id as usize,
-                                cx,
-                                |mouse_state, _| {
-                                    let button_style =
-                                        theme.chat_panel.icon_button.style_for(mouse_state);
-                                    render_icon_button(button_style, "icons/x.svg")
-                                        .aligned()
-                                        .into_any()
-                                },
+                            .with_child(render_remove(message_id_to_remove, cx, &theme))
+                            .align_children_center(),
+                    )
+                    .with_child(
+                        Flex::row()
+                            .with_child(
+                                text.element(
+                                    theme.editor.syntax.clone(),
+                                    style.body.clone(),
+                                    theme.editor.document_highlight_read_background,
+                                    cx,
+                                )
+                                .flex(1., true),
                             )
-                            .with_padding(Padding::uniform(2.))
-                            .with_cursor_style(CursorStyle::PointingHand)
-                            .on_click(MouseButton::Left, move |_, this, cx| {
-                                this.remove_message(id, cx);
-                            })
-                            .flex_float()
-                        }))
-                        .align_children_center(),
-                )
-                .with_child(Text::new(body, style.body.clone()))
-                .contained()
-                .with_style(style.container)
-                .with_margin_bottom(if is_last {
-                    theme.chat_panel.last_message_bottom_spacing
-                } else {
-                    0.
-                })
-                .into_any()
-        }
+                            // Add a spacer to make everything line up
+                            .with_child(render_remove(None, cx, &theme)),
+                    )
+                    .contained()
+                    .with_style(*container)
+                    .with_margin_bottom(if is_last {
+                        theme.chat_panel.last_message_bottom_spacing
+                    } else {
+                        0.
+                    })
+                    .into_any()
+            }
+        })
+        .into_any()
     }
 
     fn render_input_box(&self, theme: &Arc<Theme>, cx: &AppContext) -> AnyElement<Self> {
@@ -634,6 +650,7 @@ impl ChatPanel {
         cx.spawn(|this, mut cx| async move {
             let chat = open_chat.await?;
             this.update(&mut cx, |this, cx| {
+                this.markdown_data = Default::default();
                 this.set_active_chat(chat, cx);
             })
         })
@@ -658,6 +675,72 @@ impl ChatPanel {
     }
 }
 
+fn render_avatar(avatar: Option<Arc<ImageData>>, theme: &Arc<Theme>) -> AnyElement<ChatPanel> {
+    let avatar_style = theme.chat_panel.avatar;
+
+    avatar
+        .map(|avatar| {
+            Image::from_data(avatar)
+                .with_style(avatar_style.image)
+                .aligned()
+                .contained()
+                .with_corner_radius(avatar_style.outer_corner_radius)
+                .constrained()
+                .with_width(avatar_style.outer_width)
+                .with_height(avatar_style.outer_width)
+                .into_any()
+        })
+        .unwrap_or_else(|| {
+            Empty::new()
+                .constrained()
+                .with_width(avatar_style.outer_width)
+                .into_any()
+        })
+        .contained()
+        .with_style(theme.chat_panel.avatar_container)
+        .into_any()
+}
+
+fn render_remove(
+    message_id_to_remove: Option<u64>,
+    cx: &mut ViewContext<'_, '_, ChatPanel>,
+    theme: &Arc<Theme>,
+) -> AnyElement<ChatPanel> {
+    enum DeleteMessage {}
+
+    message_id_to_remove
+        .map(|id| {
+            MouseEventHandler::new::<DeleteMessage, _>(id as usize, cx, |mouse_state, _| {
+                let button_style = theme.chat_panel.icon_button.style_for(mouse_state);
+                render_icon_button(button_style, "icons/x.svg")
+                    .aligned()
+                    .into_any()
+            })
+            .with_padding(Padding::uniform(2.))
+            .with_cursor_style(CursorStyle::PointingHand)
+            .on_click(MouseButton::Left, move |_, this, cx| {
+                this.remove_message(id, cx);
+            })
+            .flex_float()
+            .into_any()
+        })
+        .unwrap_or_else(|| {
+            let style = theme.chat_panel.icon_button.default;
+
+            Empty::new()
+                .constrained()
+                .with_width(style.icon_width)
+                .aligned()
+                .constrained()
+                .with_width(style.button_width)
+                .with_height(style.button_width)
+                .contained()
+                .with_uniform_padding(2.)
+                .flex_float()
+                .into_any()
+        })
+}
+
 impl Entity for ChatPanel {
     type Event = Event;
 }

crates/collab_ui/src/collab_panel.rs 🔗

@@ -1976,11 +1976,7 @@ impl CollabPanel {
                                 .left()
                                 .with_tooltip::<ChannelTooltip>(
                                     ix,
-                                    if is_active {
-                                        "Open channel notes"
-                                    } else {
-                                        "Join channel"
-                                    },
+                                    "Join channel",
                                     None,
                                     theme.tooltip.clone(),
                                     cx,

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/rich_text/Cargo.toml 🔗

@@ -0,0 +1,30 @@
+[package]
+name = "rich_text"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+[lib]
+path = "src/rich_text.rs"
+doctest = false
+
+[features]
+test-support = [
+    "gpui/test-support",
+    "util/test-support",
+]
+
+
+[dependencies]
+collections = { path = "../collections" }
+gpui = { path = "../gpui" }
+sum_tree = { path = "../sum_tree" }
+theme = { path = "../theme" }
+language = { path = "../language" }
+util = { path = "../util" }
+anyhow.workspace = true
+futures.workspace = true
+lazy_static.workspace = true
+pulldown-cmark = { version = "0.9.2", default-features = false }
+smallvec.workspace = true
+smol.workspace = true

crates/rich_text/src/rich_text.rs 🔗

@@ -0,0 +1,287 @@
+use std::{ops::Range, sync::Arc};
+
+use futures::FutureExt;
+use gpui::{
+    color::Color,
+    elements::Text,
+    fonts::{HighlightStyle, TextStyle, Underline, Weight},
+    platform::{CursorStyle, MouseButton},
+    AnyElement, CursorRegion, Element, MouseRegion, ViewContext,
+};
+use language::{HighlightId, Language, LanguageRegistry};
+use theme::SyntaxTheme;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Highlight {
+    Id(HighlightId),
+    Highlight(HighlightStyle),
+}
+
+#[derive(Debug, Clone)]
+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)]
+pub struct RenderedRegion {
+    code: bool,
+    link_url: Option<String>,
+}
+
+impl RichText {
+    pub fn element<V: 'static>(
+        &self,
+        syntax: Arc<SyntaxTheme>,
+        style: TextStyle,
+        code_span_background_color: Color,
+        cx: &mut ViewContext<V>,
+    ) -> AnyElement<V> {
+        let mut region_id = 0;
+        let view_id = cx.view_id();
+
+        let regions = self.regions.clone();
+
+        enum Markdown {}
+        Text::new(self.text.clone(), style.clone())
+            .with_highlights(
+                self.highlights
+                    .iter()
+                    .filter_map(|(range, highlight)| {
+                        let style = match highlight {
+                            Highlight::Id(id) => id.style(&syntax)?,
+                            Highlight::Highlight(style) => style.clone(),
+                        };
+                        Some((range.clone(), style))
+                    })
+                    .collect::<Vec<_>>(),
+            )
+            .with_custom_runs(self.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::<Markdown>(view_id, region_id, bounds)
+                            .on_click::<V, _>(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)
+            .into_any()
+    }
+}
+
+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;
+    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, Options::all()) {
+        let prev_len = data.text.len();
+        match event {
+            Event::Text(t) => {
+                if let Some(language) = &current_language {
+                    render_code(&mut data.text, &mut data.highlights, t.as_ref(), language);
+                } else {
+                    data.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() {
+                        data.region_ranges.push(prev_len..data.text.len());
+                        data.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)) = data.highlights.last_mut() {
+                            if last_range.end == prev_len
+                                && last_style == &Highlight::Highlight(style)
+                            {
+                                last_range.end = data.text.len();
+                                new_highlight = false;
+                            }
+                        }
+                        if new_highlight {
+                            data.highlights
+                                .push((prev_len..data.text.len(), Highlight::Highlight(style)));
+                        }
+                    }
+                }
+            }
+            Event::Code(t) => {
+                data.text.push_str(t.as_ref());
+                data.region_ranges.push(prev_len..data.text.len());
+                if link_url.is_some() {
+                    data.highlights.push((
+                        prev_len..data.text.len(),
+                        Highlight::Highlight(HighlightStyle {
+                            underline: Some(Underline {
+                                thickness: 1.0.into(),
+                                ..Default::default()
+                            }),
+                            ..Default::default()
+                        }),
+                    ));
+                }
+                data.regions.push(RenderedRegion {
+                    code: true,
+                    link_url: link_url.clone(),
+                });
+            }
+            Event::Start(tag) => match tag {
+                Tag::Paragraph => new_paragraph(&mut data.text, &mut list_stack),
+                Tag::Heading(_, _, _) => {
+                    new_paragraph(&mut data.text, &mut list_stack);
+                    bold_depth += 1;
+                }
+                Tag::CodeBlock(kind) => {
+                    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 {
+                        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 !data.text.is_empty() && !data.text.ends_with('\n') {
+                            data.text.push('\n');
+                        }
+                        for _ in 0..len - 1 {
+                            data.text.push_str("  ");
+                        }
+                        if let Some(number) = list_number {
+                            data.text.push_str(&format!("{}. ", number));
+                            *number += 1;
+                            *has_content = false;
+                        } else {
+                            data.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 => data.text.push('\n'),
+            Event::SoftBreak => data.text.push(' '),
+            _ => {}
+        }
+    }
+}
+
+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
+}
+
+pub fn render_code(
+    text: &mut String,
+    highlights: &mut Vec<(Range<usize>, Highlight)>,
+    content: &str,
+    language: &Arc<Language>,
+) {
+    let prev_len = text.len();
+    text.push_str(content);
+    for (range, highlight_id) in language.highlight_text(&content.into(), 0..content.len()) {
+        highlights.push((
+            prev_len + range.start..prev_len + range.end,
+            Highlight::Id(highlight_id),
+        ));
+    }
+}
+
+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 {
+            is_subsequent_paragraph_of_list = true;
+        } else {
+            *has_content = true;
+            return;
+        }
+    }
+
+    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("  ");
+    }
+}

crates/theme/src/theme.rs 🔗

@@ -634,6 +634,8 @@ pub struct ChatPanel {
     pub list: ContainerStyle,
     pub channel_select: ChannelSelect,
     pub input_editor: FieldEditor,
+    pub avatar: AvatarStyle,
+    pub avatar_container: ContainerStyle,
     pub message: ChatMessage,
     pub continuation_message: ChatMessage,
     pub last_message_bottom_spacing: f32,
@@ -645,7 +647,7 @@ pub struct ChatPanel {
 #[derive(Deserialize, Default, JsonSchema)]
 pub struct ChatMessage {
     #[serde(flatten)]
-    pub container: ContainerStyle,
+    pub container: Interactive<ContainerStyle>,
     pub body: TextStyle,
     pub sender: ContainedText,
     pub timestamp: ContainedText,

styles/src/style_tree/chat_panel.ts 🔗

@@ -5,6 +5,7 @@ import {
 } from "./components"
 import { icon_button } from "../component/icon_button"
 import { useTheme } from "../theme"
+import { interactive } from "../element"
 
 export default function chat_panel(): any {
     const theme = useTheme()
@@ -27,11 +28,23 @@ export default function chat_panel(): any {
 
     return {
         background: background(layer),
-        list: {
-            margin: {
-                left: SPACING,
-                right: SPACING,
+        avatar: {
+            icon_width: 24,
+            icon_height: 24,
+            corner_radius: 4,
+            outer_width: 24,
+            outer_corner_radius: 16,
+        },
+        avatar_container: {
+            padding: {
+                right: 6,
+                left: 2,
+                top: 2,
+                bottom: 2,
             }
+        },
+        list: {
+
         },
         channel_select: {
             header: {
@@ -79,6 +92,22 @@ export default function chat_panel(): any {
             },
         },
         message: {
+            ...interactive({
+                base: {
+                    margin: { top: SPACING },
+                    padding: {
+                        top: 4,
+                        bottom: 4,
+                        left: SPACING / 2,
+                        right: SPACING / 3,
+                    }
+                },
+                state: {
+                    hovered: {
+                        background: background(layer, "hovered"),
+                    },
+                },
+            }),
             body: text(layer, "sans", "base"),
             sender: {
                 margin: {
@@ -87,7 +116,6 @@ export default function chat_panel(): any {
                 ...text(layer, "sans", "base", { weight: "bold" }),
             },
             timestamp: text(layer, "sans", "base", "disabled"),
-            margin: { top: SPACING }
         },
         last_message_bottom_spacing: SPACING,
         continuation_message: {
@@ -99,7 +127,21 @@ export default function chat_panel(): any {
                 ...text(layer, "sans", "base", { weight: "bold" }),
             },
             timestamp: text(layer, "sans", "base", "disabled"),
-
+            ...interactive({
+                base: {
+                    padding: {
+                        top: 4,
+                        bottom: 4,
+                        left: SPACING / 2,
+                        right: SPACING / 3,
+                    }
+                },
+                state: {
+                    hovered: {
+                        background: background(layer, "hovered"),
+                    },
+                },
+            }),
         },
         pending_message: {
             body: text(layer, "sans", "base"),
@@ -110,6 +152,21 @@ export default function chat_panel(): any {
                 ...text(layer, "sans", "base", "disabled"),
             },
             timestamp: text(layer, "sans", "base"),
+            ...interactive({
+                base: {
+                    padding: {
+                        top: 4,
+                        bottom: 4,
+                        left: SPACING / 2,
+                        right: SPACING / 3,
+                    }
+                },
+                state: {
+                    hovered: {
+                        background: background(layer, "hovered"),
+                    },
+                },
+            }),
         },
         sign_in_prompt: {
             default: text(layer, "sans", "base"),