Detailed changes
@@ -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"
@@ -64,6 +64,7 @@ members = [
"crates/sqlez",
"crates/sqlez_macros",
"crates/feature_flags",
+ "crates/rich_text",
"crates/storybook",
"crates/sum_tree",
"crates/terminal",
@@ -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),
@@ -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"}
@@ -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;
}
@@ -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,
@@ -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" }
@@ -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) = ¤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,
- }
-}
-
-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:?}"
);
}
@@ -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
@@ -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) = ¤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<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(" ");
+ }
+}
@@ -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,
@@ -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"),