From 6f4dee5b1d12ffe84e9b30e7f1e0a3e94387a0ec Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Thu, 5 Oct 2023 14:30:12 -0700 Subject: [PATCH] Add markdown parsing to channel chat (#3088) 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 --- 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(-) create mode 100644 crates/rich_text/Cargo.toml create mode 100644 crates/rich_text/src/rich_text.rs diff --git a/Cargo.lock b/Cargo.lock index 0e3d694229..d0f311cd60 100644 --- a/Cargo.lock +++ b/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" diff --git a/Cargo.toml b/Cargo.toml index 05a013a4e0..7dae3bd81f 100644 --- a/Cargo.toml +++ b/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", diff --git a/crates/channel/src/channel_chat.rs b/crates/channel/src/channel_chat.rs index 29a260ea7e..734182886b 100644 --- a/crates/channel/src/channel_chat.rs +++ b/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), diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index b6e45471f1..98790778c9 100644 --- a/crates/collab_ui/Cargo.toml +++ b/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"} diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index 41bc5fbd08..b446521c5a 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/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, channel_store: ModelHandle, + languages: Arc, active_chat: Option<(ModelHandle, Subscription)>, message_list: ListState, input_editor: ViewHandle, @@ -47,6 +50,7 @@ pub struct ChatPanel { subscriptions: Vec, workspace: WeakViewHandle, has_focus: bool, + markdown_data: HashMap, } #[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) -> AnyElement { + fn render_message(&mut self, ix: usize, cx: &mut ViewContext) -> AnyElement { 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::(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() + enum MessageBackgroundHighlight {} + MouseEventHandler::new::(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. }) - .with_padding(Padding::uniform(2.)) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, this, cx| { - this.remove_message(id, cx); - }) - .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.timestamp.container), + ) + .align_children_center() + .flex(1., true), ) - .contained() - .with_style(style.sender.container), - ) - .with_child( - Label::new( - format_timestamp(message.timestamp, now, self.local_timezone), - style.timestamp.text.clone(), + .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), ) - .contained() - .with_style(style.timestamp.container), - ) - .with_children(message_id_to_remove.map(|id| { - MouseEventHandler::new::( - 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() - })) - .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, cx: &AppContext) -> AnyElement { @@ -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>, theme: &Arc) -> AnyElement { + 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, + cx: &mut ViewContext<'_, '_, ChatPanel>, + theme: &Arc, +) -> AnyElement { + enum DeleteMessage {} + + message_id_to_remove + .map(|id| { + MouseEventHandler::new::(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; } diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 66913c2da7..951c8bf70c 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -1976,11 +1976,7 @@ impl CollabPanel { .left() .with_tooltip::( ix, - if is_active { - "Open channel notes" - } else { - "Join channel" - }, + "Join channel", None, theme.tooltip.clone(), cx, diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index b0f8323a76..2c3d6227a9 100644 --- a/crates/editor/Cargo.toml +++ b/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" } diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index f460b18bce..553cb321c3 100644 --- a/crates/editor/src/hover_popover.rs +++ b/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, language: Option<&Arc>, - 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) = ¤t_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, - } -} + data.text = data.text.trim().to_string(); -fn render_code( - text: &mut String, - highlights: &mut Vec<(Range, HighlightStyle)>, - content: &str, - language: &Arc, - 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, 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(" "); - } + data } #[derive(Default)] @@ -623,22 +445,7 @@ pub struct InfoPopover { symbol_range: RangeInEditor, pub blocks: Vec, language: Option>, - rendered_content: Option, -} - -#[derive(Debug, Clone)] -struct RenderedInfo { - theme_id: usize, - text: String, - highlights: Vec<(Range, HighlightStyle)>, - region_ranges: Vec>, - regions: Vec, -} - -#[derive(Debug, Clone)] -struct RenderedRegion { - code: bool, - link_url: Option, + rendered_content: Option, } impl InfoPopover { @@ -647,63 +454,24 @@ impl InfoPopover { style: &EditorStyle, cx: &mut ViewContext, ) -> AnyElement { - 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::(0, cx, |_, cx| { - let mut region_id = 0; - let view_id = cx.view_id(); - + MouseEventHandler::new::(0, cx, move |_, cx| { let code_span_background_color = style.document_highlight_read_background; - let regions = rendered_content.regions.clone(); Flex::column() .scrollable::(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::(view_id, region_id, bounds) - .on_click::( - 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:?}" ); } diff --git a/crates/rich_text/Cargo.toml b/crates/rich_text/Cargo.toml new file mode 100644 index 0000000000..3d2c25406d --- /dev/null +++ b/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 diff --git a/crates/rich_text/src/rich_text.rs b/crates/rich_text/src/rich_text.rs new file mode 100644 index 0000000000..72c7bdf6c1 --- /dev/null +++ b/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, Highlight)>, + pub region_ranges: Vec>, + pub regions: Vec, +} + +#[derive(Debug, Clone)] +pub struct RenderedRegion { + code: bool, + link_url: Option, +} + +impl RichText { + pub fn element( + &self, + syntax: Arc, + style: TextStyle, + code_span_background_color: Color, + cx: &mut ViewContext, + ) -> AnyElement { + 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::>(), + ) + .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::(view_id, region_id, bounds) + .on_click::(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, + language: Option<&Arc>, + 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) = ¤t_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, + language: Option<&Arc>, +) -> 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, Highlight)>, + content: &str, + language: &Arc, +) { + 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, 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(" "); + } +} diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 63241668c4..e534ba4260 100644 --- a/crates/theme/src/theme.rs +++ b/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, pub body: TextStyle, pub sender: ContainedText, pub timestamp: ContainedText, diff --git a/styles/src/style_tree/chat_panel.ts b/styles/src/style_tree/chat_panel.ts index 466d25f43d..829540de30 100644 --- a/styles/src/style_tree/chat_panel.ts +++ b/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"),