diff --git a/Cargo.lock b/Cargo.lock index 8c2a2d4e7d..535ef49c09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4527,9 +4527,9 @@ dependencies = [ "lazy_static", "log", "menu", + "pretty_assertions", "project", "pulldown-cmark", - "rich_text", "theme", "ui", "util", diff --git a/crates/markdown_preview/Cargo.toml b/crates/markdown_preview/Cargo.toml index ba23a82259..f07447ef3f 100644 --- a/crates/markdown_preview/Cargo.toml +++ b/crates/markdown_preview/Cargo.toml @@ -20,8 +20,8 @@ lazy_static.workspace = true log.workspace = true menu.workspace = true project.workspace = true +pretty_assertions.workspace = true pulldown-cmark.workspace = true -rich_text.workspace = true theme.workspace = true ui.workspace = true util.workspace = true diff --git a/crates/markdown_preview/src/markdown_elements.rs b/crates/markdown_preview/src/markdown_elements.rs new file mode 100644 index 0000000000..54cd01f8cd --- /dev/null +++ b/crates/markdown_preview/src/markdown_elements.rs @@ -0,0 +1,242 @@ +use gpui::{px, FontStyle, FontWeight, HighlightStyle, SharedString, UnderlineStyle}; +use language::HighlightId; +use std::{ops::Range, path::PathBuf}; + +#[derive(Debug)] +#[cfg_attr(test, derive(PartialEq))] +pub enum ParsedMarkdownElement { + Heading(ParsedMarkdownHeading), + /// An ordered or unordered list of items. + List(ParsedMarkdownList), + Table(ParsedMarkdownTable), + BlockQuote(ParsedMarkdownBlockQuote), + CodeBlock(ParsedMarkdownCodeBlock), + /// A paragraph of text and other inline elements. + Paragraph(ParsedMarkdownText), + HorizontalRule(Range), +} + +impl ParsedMarkdownElement { + pub fn source_range(&self) -> Range { + match self { + Self::Heading(heading) => heading.source_range.clone(), + Self::List(list) => list.source_range.clone(), + Self::Table(table) => table.source_range.clone(), + Self::BlockQuote(block_quote) => block_quote.source_range.clone(), + Self::CodeBlock(code_block) => code_block.source_range.clone(), + Self::Paragraph(text) => text.source_range.clone(), + Self::HorizontalRule(range) => range.clone(), + } + } +} + +#[derive(Debug)] +#[cfg_attr(test, derive(PartialEq))] +pub struct ParsedMarkdown { + pub children: Vec, +} + +#[derive(Debug)] +#[cfg_attr(test, derive(PartialEq))] +pub struct ParsedMarkdownList { + pub source_range: Range, + pub children: Vec, +} + +#[derive(Debug)] +#[cfg_attr(test, derive(PartialEq))] +pub struct ParsedMarkdownListItem { + /// How many indentations deep this item is. + pub depth: u16, + pub item_type: ParsedMarkdownListItemType, + pub contents: Vec>, +} + +#[derive(Debug)] +#[cfg_attr(test, derive(PartialEq))] +pub enum ParsedMarkdownListItemType { + Ordered(u64), + Task(bool), + Unordered, +} + +#[derive(Debug)] +#[cfg_attr(test, derive(PartialEq))] +pub struct ParsedMarkdownCodeBlock { + pub source_range: Range, + pub language: Option, + pub contents: SharedString, +} + +#[derive(Debug)] +#[cfg_attr(test, derive(PartialEq))] +pub struct ParsedMarkdownHeading { + pub source_range: Range, + pub level: HeadingLevel, + pub contents: ParsedMarkdownText, +} + +#[derive(Debug, PartialEq)] +pub enum HeadingLevel { + H1, + H2, + H3, + H4, + H5, + H6, +} + +#[derive(Debug)] +pub struct ParsedMarkdownTable { + pub source_range: Range, + pub header: ParsedMarkdownTableRow, + pub body: Vec, + pub column_alignments: Vec, +} + +#[derive(Debug, Clone, Copy)] +#[cfg_attr(test, derive(PartialEq))] +pub enum ParsedMarkdownTableAlignment { + /// Default text alignment. + None, + Left, + Center, + Right, +} + +#[derive(Debug)] +#[cfg_attr(test, derive(PartialEq))] +pub struct ParsedMarkdownTableRow { + pub children: Vec, +} + +impl ParsedMarkdownTableRow { + pub fn new() -> Self { + Self { + children: Vec::new(), + } + } + + pub fn with_children(children: Vec) -> Self { + Self { children } + } +} + +#[derive(Debug)] +#[cfg_attr(test, derive(PartialEq))] +pub struct ParsedMarkdownBlockQuote { + pub source_range: Range, + pub children: Vec>, +} + +#[derive(Debug)] +pub struct ParsedMarkdownText { + /// Where the text is located in the source Markdown document. + pub source_range: Range, + /// The text content stripped of any formatting symbols. + pub contents: String, + /// The list of highlights contained in the Markdown document. + pub highlights: Vec<(Range, MarkdownHighlight)>, + /// The regions of the various ranges in the Markdown document. + pub region_ranges: Vec>, + /// The regions of the Markdown document. + pub regions: Vec, +} + +/// A run of highlighted Markdown text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MarkdownHighlight { + /// A styled Markdown highlight. + Style(MarkdownHighlightStyle), + /// A highlighted code block. + Code(HighlightId), +} + +impl MarkdownHighlight { + /// Converts this [`MarkdownHighlight`] to a [`HighlightStyle`]. + pub fn to_highlight_style(&self, theme: &theme::SyntaxTheme) -> Option { + match self { + MarkdownHighlight::Style(style) => { + let mut highlight = HighlightStyle::default(); + + if style.italic { + highlight.font_style = Some(FontStyle::Italic); + } + + if style.underline { + highlight.underline = Some(UnderlineStyle { + thickness: px(1.), + ..Default::default() + }); + } + + if style.weight != FontWeight::default() { + highlight.font_weight = Some(style.weight); + } + + Some(highlight) + } + + MarkdownHighlight::Code(id) => id.style(theme), + } + } +} + +/// The style for a Markdown highlight. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct MarkdownHighlightStyle { + /// Whether the text should be italicized. + pub italic: bool, + /// Whether the text should be underlined. + pub underline: bool, + /// The weight of the text. + pub weight: FontWeight, +} + +/// A parsed region in a Markdown document. +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub struct ParsedRegion { + /// Whether the region is a code block. + pub code: bool, + /// The link contained in this region, if it has one. + pub link: Option, +} + +/// A Markdown link. +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub enum Link { + /// A link to a webpage. + Web { + /// The URL of the webpage. + url: String, + }, + /// A link to a path on the filesystem. + Path { + /// The path to the item. + path: PathBuf, + }, +} + +impl Link { + pub fn identify(file_location_directory: Option, text: String) -> Option { + if text.starts_with("http") { + return Some(Link::Web { url: text }); + } + + let path = PathBuf::from(&text); + if path.is_absolute() && path.exists() { + return Some(Link::Path { path }); + } + + if let Some(file_location_directory) = file_location_directory { + let path = file_location_directory.join(text); + if path.exists() { + return Some(Link::Path { path }); + } + } + + None + } +} diff --git a/crates/markdown_preview/src/markdown_parser.rs b/crates/markdown_preview/src/markdown_parser.rs new file mode 100644 index 0000000000..f860e211e2 --- /dev/null +++ b/crates/markdown_preview/src/markdown_parser.rs @@ -0,0 +1,1110 @@ +use crate::markdown_elements::*; +use gpui::FontWeight; +use pulldown_cmark::{Alignment, Event, Options, Parser, Tag}; +use std::{ops::Range, path::PathBuf}; + +pub fn parse_markdown( + markdown_input: &str, + file_location_directory: Option, +) -> ParsedMarkdown { + let options = Options::all(); + let parser = Parser::new_ext(markdown_input, options); + let parser = MarkdownParser::new(parser.into_offset_iter().collect(), file_location_directory); + let renderer = parser.parse_document(); + ParsedMarkdown { + children: renderer.parsed, + } +} + +struct MarkdownParser<'a> { + tokens: Vec<(Event<'a>, Range)>, + /// The current index in the tokens array + cursor: usize, + /// The blocks that we have successfully parsed so far + parsed: Vec, + file_location_directory: Option, +} + +impl<'a> MarkdownParser<'a> { + fn new( + tokens: Vec<(Event<'a>, Range)>, + file_location_directory: Option, + ) -> Self { + Self { + tokens, + file_location_directory, + cursor: 0, + parsed: vec![], + } + } + + fn eof(&self) -> bool { + if self.tokens.is_empty() { + return true; + } + self.cursor >= self.tokens.len() - 1 + } + + fn peek(&self, steps: usize) -> Option<&(Event, Range)> { + if self.eof() || (steps + self.cursor) >= self.tokens.len() { + return self.tokens.last(); + } + return self.tokens.get(self.cursor + steps); + } + + fn previous(&self) -> Option<&(Event, Range)> { + if self.cursor == 0 || self.cursor > self.tokens.len() { + return None; + } + return self.tokens.get(self.cursor - 1); + } + + fn current(&self) -> Option<&(Event, Range)> { + return self.peek(0); + } + + fn is_text_like(event: &Event) -> bool { + match event { + Event::Text(_) + // Represent an inline code block + | Event::Code(_) + | Event::Html(_) + | Event::FootnoteReference(_) + | Event::Start(Tag::Link(_, _, _)) + | Event::Start(Tag::Emphasis) + | Event::Start(Tag::Strong) + | Event::Start(Tag::Strikethrough) + | Event::Start(Tag::Image(_, _, _)) => { + return true; + } + _ => return false, + } + } + + fn parse_document(mut self) -> Self { + while !self.eof() { + if let Some(block) = self.parse_block() { + self.parsed.push(block); + } + } + self + } + + fn parse_block(&mut self) -> Option { + let (current, source_range) = self.current().unwrap(); + match current { + Event::Start(tag) => match tag { + Tag::Paragraph => { + self.cursor += 1; + let text = self.parse_text(false); + Some(ParsedMarkdownElement::Paragraph(text)) + } + Tag::Heading(level, _, _) => { + let level = level.clone(); + self.cursor += 1; + let heading = self.parse_heading(level); + Some(ParsedMarkdownElement::Heading(heading)) + } + Tag::Table(_) => { + self.cursor += 1; + let table = self.parse_table(); + Some(ParsedMarkdownElement::Table(table)) + } + Tag::List(order) => { + let order = order.clone(); + self.cursor += 1; + let list = self.parse_list(1, order); + Some(ParsedMarkdownElement::List(list)) + } + Tag::BlockQuote => { + self.cursor += 1; + let block_quote = self.parse_block_quote(); + Some(ParsedMarkdownElement::BlockQuote(block_quote)) + } + Tag::CodeBlock(kind) => { + let language = match kind { + pulldown_cmark::CodeBlockKind::Indented => None, + pulldown_cmark::CodeBlockKind::Fenced(language) => { + if language.is_empty() { + None + } else { + Some(language.to_string()) + } + } + }; + + self.cursor += 1; + + let code_block = self.parse_code_block(language); + Some(ParsedMarkdownElement::CodeBlock(code_block)) + } + _ => { + self.cursor += 1; + None + } + }, + Event::Rule => { + let source_range = source_range.clone(); + self.cursor += 1; + Some(ParsedMarkdownElement::HorizontalRule(source_range)) + } + _ => { + self.cursor += 1; + None + } + } + } + + fn parse_text(&mut self, should_complete_on_soft_break: bool) -> ParsedMarkdownText { + let (_current, source_range) = self.previous().unwrap(); + let source_range = source_range.clone(); + + let mut text = String::new(); + let mut bold_depth = 0; + let mut italic_depth = 0; + let mut link: Option = None; + let mut region_ranges: Vec> = vec![]; + let mut regions: Vec = vec![]; + let mut highlights: Vec<(Range, MarkdownHighlight)> = vec![]; + + loop { + if self.eof() { + break; + } + + let (current, _source_range) = self.current().unwrap(); + let prev_len = text.len(); + match current { + Event::SoftBreak => { + if should_complete_on_soft_break { + break; + } + + // `Some text\nSome more text` should be treated as a single line. + text.push(' '); + } + + Event::HardBreak => { + break; + } + + Event::Text(t) => { + text.push_str(t.as_ref()); + + let mut style = MarkdownHighlightStyle::default(); + + if bold_depth > 0 { + style.weight = FontWeight::BOLD; + } + + if italic_depth > 0 { + style.italic = true; + } + + if let Some(link) = link.clone() { + region_ranges.push(prev_len..text.len()); + regions.push(ParsedRegion { + code: false, + link: Some(link), + }); + style.underline = true; + } + + if style != MarkdownHighlightStyle::default() { + let mut new_highlight = true; + if let Some((last_range, MarkdownHighlight::Style(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 { + let range = prev_len..text.len(); + highlights.push((range, MarkdownHighlight::Style(style))); + } + } + } + + // Note: This event means "inline code" and not "code block" + Event::Code(t) => { + text.push_str(t.as_ref()); + region_ranges.push(prev_len..text.len()); + + if link.is_some() { + highlights.push(( + prev_len..text.len(), + MarkdownHighlight::Style(MarkdownHighlightStyle { + underline: true, + ..Default::default() + }), + )); + } + + regions.push(ParsedRegion { + code: true, + link: link.clone(), + }); + } + + Event::Start(tag) => { + match tag { + Tag::Emphasis => italic_depth += 1, + Tag::Strong => bold_depth += 1, + Tag::Link(_type, url, _title) => { + link = Link::identify( + self.file_location_directory.clone(), + url.to_string(), + ); + } + Tag::Strikethrough => { + // TODO: Confirm that gpui currently doesn't support strikethroughs + } + _ => { + break; + } + } + } + + Event::End(tag) => match tag { + Tag::Emphasis => { + italic_depth -= 1; + } + Tag::Strong => { + bold_depth -= 1; + } + Tag::Link(_, _, _) => { + link = None; + } + Tag::Strikethrough => { + // TODO: Confirm that gpui currently doesn't support strikethroughs + } + Tag::Paragraph => { + self.cursor += 1; + break; + } + _ => { + break; + } + }, + + _ => { + break; + } + } + + self.cursor += 1; + } + + ParsedMarkdownText { + source_range, + contents: text, + highlights, + regions, + region_ranges, + } + } + + fn parse_heading(&mut self, level: pulldown_cmark::HeadingLevel) -> ParsedMarkdownHeading { + let (_event, source_range) = self.previous().unwrap(); + let source_range = source_range.clone(); + let text = self.parse_text(true); + + // Advance past the heading end tag + self.cursor += 1; + + ParsedMarkdownHeading { + source_range: source_range.clone(), + level: match level { + pulldown_cmark::HeadingLevel::H1 => HeadingLevel::H1, + pulldown_cmark::HeadingLevel::H2 => HeadingLevel::H2, + pulldown_cmark::HeadingLevel::H3 => HeadingLevel::H3, + pulldown_cmark::HeadingLevel::H4 => HeadingLevel::H4, + pulldown_cmark::HeadingLevel::H5 => HeadingLevel::H5, + pulldown_cmark::HeadingLevel::H6 => HeadingLevel::H6, + }, + contents: text, + } + } + + fn parse_table(&mut self) -> ParsedMarkdownTable { + let (_event, source_range) = self.previous().unwrap(); + let source_range = source_range.clone(); + let mut header = ParsedMarkdownTableRow::new(); + let mut body = vec![]; + let mut current_row = vec![]; + let mut in_header = true; + let mut alignment: Vec = vec![]; + + loop { + if self.eof() { + break; + } + + let (current, _source_range) = self.current().unwrap(); + match current { + Event::Start(Tag::TableHead) + | Event::Start(Tag::TableRow) + | Event::End(Tag::TableCell) => { + self.cursor += 1; + } + Event::Start(Tag::TableCell) => { + self.cursor += 1; + let cell_contents = self.parse_text(false); + current_row.push(cell_contents); + } + Event::End(Tag::TableHead) | Event::End(Tag::TableRow) => { + self.cursor += 1; + let new_row = std::mem::replace(&mut current_row, vec![]); + if in_header { + header.children = new_row; + in_header = false; + } else { + let row = ParsedMarkdownTableRow::with_children(new_row); + body.push(row); + } + } + Event::End(Tag::Table(table_alignment)) => { + alignment = table_alignment + .iter() + .map(|a| Self::convert_alignment(a)) + .collect(); + self.cursor += 1; + break; + } + _ => { + break; + } + } + } + + ParsedMarkdownTable { + source_range, + header, + body, + column_alignments: alignment, + } + } + + fn convert_alignment(alignment: &Alignment) -> ParsedMarkdownTableAlignment { + match alignment { + Alignment::None => ParsedMarkdownTableAlignment::None, + Alignment::Left => ParsedMarkdownTableAlignment::Left, + Alignment::Center => ParsedMarkdownTableAlignment::Center, + Alignment::Right => ParsedMarkdownTableAlignment::Right, + } + } + + fn parse_list(&mut self, depth: u16, order: Option) -> ParsedMarkdownList { + let (_event, source_range) = self.previous().unwrap(); + let source_range = source_range.clone(); + let mut children = vec![]; + let mut inside_list_item = false; + let mut order = order; + let mut task_item = None; + + let mut current_list_items: Vec> = vec![]; + + while !self.eof() { + let (current, _source_range) = self.current().unwrap(); + match current { + Event::Start(Tag::List(order)) => { + let order = order.clone(); + self.cursor += 1; + + let inner_list = self.parse_list(depth + 1, order); + let block = ParsedMarkdownElement::List(inner_list); + current_list_items.push(Box::new(block)); + } + Event::End(Tag::List(_)) => { + self.cursor += 1; + break; + } + Event::Start(Tag::Item) => { + self.cursor += 1; + inside_list_item = true; + + // Check for task list marker (`- [ ]` or `- [x]`) + if let Some(next) = self.current() { + match next.0 { + Event::TaskListMarker(checked) => { + task_item = Some(checked); + self.cursor += 1; + } + _ => {} + } + } + + if let Some(next) = self.current() { + // This is a plain list item. + // For example `- some text` or `1. [Docs](./docs.md)` + if MarkdownParser::is_text_like(&next.0) { + let text = self.parse_text(false); + let block = ParsedMarkdownElement::Paragraph(text); + current_list_items.push(Box::new(block)); + } else { + let block = self.parse_block(); + if let Some(block) = block { + current_list_items.push(Box::new(block)); + } + } + } + } + Event::End(Tag::Item) => { + self.cursor += 1; + + let item_type = if let Some(checked) = task_item { + ParsedMarkdownListItemType::Task(checked) + } else if let Some(order) = order.clone() { + ParsedMarkdownListItemType::Ordered(order) + } else { + ParsedMarkdownListItemType::Unordered + }; + + if let Some(current) = order { + order = Some(current + 1); + } + + let contents = std::mem::replace(&mut current_list_items, vec![]); + + children.push(ParsedMarkdownListItem { + contents, + depth, + item_type, + }); + + inside_list_item = false; + task_item = None; + } + _ => { + if !inside_list_item { + break; + } + + let block = self.parse_block(); + if let Some(block) = block { + current_list_items.push(Box::new(block)); + } + } + } + } + + ParsedMarkdownList { + source_range, + children, + } + } + + fn parse_block_quote(&mut self) -> ParsedMarkdownBlockQuote { + let (_event, source_range) = self.previous().unwrap(); + let source_range = source_range.clone(); + let mut nested_depth = 1; + + let mut children: Vec> = vec![]; + + while !self.eof() { + let block = self.parse_block(); + + if let Some(block) = block { + children.push(Box::new(block)); + } else { + break; + } + + if self.eof() { + break; + } + + let (current, _source_range) = self.current().unwrap(); + match current { + // This is a nested block quote. + // Record that we're in a nested block quote and continue parsing. + // We don't need to advance the cursor since the next + // call to `parse_block` will handle it. + Event::Start(Tag::BlockQuote) => { + nested_depth += 1; + } + Event::End(Tag::BlockQuote) => { + nested_depth -= 1; + if nested_depth == 0 { + self.cursor += 1; + break; + } + } + _ => {} + }; + } + + ParsedMarkdownBlockQuote { + source_range, + children, + } + } + + fn parse_code_block(&mut self, language: Option) -> ParsedMarkdownCodeBlock { + let (_event, source_range) = self.previous().unwrap(); + let source_range = source_range.clone(); + let mut code = String::new(); + + while !self.eof() { + let (current, _source_range) = self.current().unwrap(); + match current { + Event::Text(text) => { + code.push_str(&text); + self.cursor += 1; + } + Event::End(Tag::CodeBlock(_)) => { + self.cursor += 1; + break; + } + _ => { + break; + } + } + } + + ParsedMarkdownCodeBlock { + source_range, + contents: code.trim().to_string().into(), + language, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use pretty_assertions::assert_eq; + + use ParsedMarkdownElement::*; + use ParsedMarkdownListItemType::*; + + fn parse(input: &str) -> ParsedMarkdown { + parse_markdown(input, None) + } + + #[test] + fn test_headings() { + let parsed = parse("# Heading one\n## Heading two\n### Heading three"); + + assert_eq!( + parsed.children, + vec![ + h1(text("Heading one", 0..14), 0..14), + h2(text("Heading two", 14..29), 14..29), + h3(text("Heading three", 29..46), 29..46), + ] + ); + } + + #[test] + fn test_newlines_dont_new_paragraphs() { + let parsed = parse("Some text **that is bolded**\n and *italicized*"); + + assert_eq!( + parsed.children, + vec![p("Some text that is bolded and italicized", 0..46)] + ); + } + + #[test] + fn test_heading_with_paragraph() { + let parsed = parse("# Zed\nThe editor"); + + assert_eq!( + parsed.children, + vec![h1(text("Zed", 0..6), 0..6), p("The editor", 6..16),] + ); + } + + #[test] + fn test_double_newlines_do_new_paragraphs() { + let parsed = parse("Some text **that is bolded**\n\n and *italicized*"); + + assert_eq!( + parsed.children, + vec![ + p("Some text that is bolded", 0..29), + p("and italicized", 31..47), + ] + ); + } + + #[test] + fn test_bold_italic_text() { + let parsed = parse("Some text **that is bolded** and *italicized*"); + + assert_eq!( + parsed.children, + vec![p("Some text that is bolded and italicized", 0..45)] + ); + } + + #[test] + fn test_header_only_table() { + let markdown = "\ +| Header 1 | Header 2 | +|----------|----------| + +Some other content +"; + + let expected_table = table( + 0..48, + row(vec![text("Header 1", 1..11), text("Header 2", 12..22)]), + vec![], + ); + + assert_eq!( + parse(markdown).children[0], + ParsedMarkdownElement::Table(expected_table) + ); + } + + #[test] + fn test_basic_table() { + let markdown = "\ +| Header 1 | Header 2 | +|----------|----------| +| Cell 1 | Cell 2 | +| Cell 3 | Cell 4 |"; + + let expected_table = table( + 0..95, + row(vec![text("Header 1", 1..11), text("Header 2", 12..22)]), + vec![ + row(vec![text("Cell 1", 49..59), text("Cell 2", 60..70)]), + row(vec![text("Cell 3", 73..83), text("Cell 4", 84..94)]), + ], + ); + + assert_eq!( + parse(markdown).children[0], + ParsedMarkdownElement::Table(expected_table) + ); + } + + #[test] + fn test_list_basic() { + let parsed = parse( + "\ +* Item 1 +* Item 2 +* Item 3 +", + ); + + assert_eq!( + parsed.children, + vec![list( + vec![ + list_item(1, Unordered, vec![p("Item 1", 0..9)]), + list_item(1, Unordered, vec![p("Item 2", 9..18)]), + list_item(1, Unordered, vec![p("Item 3", 18..27)]), + ], + 0..27 + ),] + ); + } + + #[test] + fn test_list_with_tasks() { + let parsed = parse( + "\ +- [ ] TODO +- [x] Checked +", + ); + + assert_eq!( + parsed.children, + vec![list( + vec![ + list_item(1, Task(false), vec![p("TODO", 2..5)]), + list_item(1, Task(true), vec![p("Checked", 13..16)]), + ], + 0..25 + ),] + ); + } + + #[test] + fn test_list_nested() { + let parsed = parse( + "\ +* Item 1 +* Item 2 +* Item 3 + +1. Hello +1. Two + 1. Three +2. Four +3. Five + +* First + 1. Hello + 1. Goodbyte + - Inner + - Inner + 2. Goodbyte +* Last +", + ); + + assert_eq!( + parsed.children, + vec![ + list( + vec![ + list_item(1, Unordered, vec![p("Item 1", 0..9)]), + list_item(1, Unordered, vec![p("Item 2", 9..18)]), + list_item(1, Unordered, vec![p("Item 3", 18..28)]), + ], + 0..28 + ), + list( + vec![ + list_item(1, Ordered(1), vec![p("Hello", 28..37)]), + list_item( + 1, + Ordered(2), + vec![ + p("Two", 37..56), + list( + vec![list_item(2, Ordered(1), vec![p("Three", 47..56)]),], + 47..56 + ), + ] + ), + list_item(1, Ordered(3), vec![p("Four", 56..64)]), + list_item(1, Ordered(4), vec![p("Five", 64..73)]), + ], + 28..73 + ), + list( + vec![ + list_item( + 1, + Unordered, + vec![ + p("First", 73..155), + list( + vec![ + list_item( + 2, + Ordered(1), + vec![ + p("Hello", 83..141), + list( + vec![list_item( + 3, + Ordered(1), + vec![ + p("Goodbyte", 97..141), + list( + vec![ + list_item( + 4, + Unordered, + vec![p("Inner", 117..125)] + ), + list_item( + 4, + Unordered, + vec![p("Inner", 133..141)] + ), + ], + 117..141 + ) + ] + ),], + 97..141 + ) + ] + ), + list_item(2, Ordered(2), vec![p("Goodbyte", 143..155)]), + ], + 83..155 + ) + ] + ), + list_item(1, Unordered, vec![p("Last", 155..162)]), + ], + 73..162 + ), + ] + ); + } + + #[test] + fn test_list_with_nested_content() { + let parsed = parse( + "\ +* This is a list item with two paragraphs. + + This is the second paragraph in the list item.", + ); + + assert_eq!( + parsed.children, + vec![list( + vec![list_item( + 1, + Unordered, + vec![ + p("This is a list item with two paragraphs.", 4..45), + p("This is the second paragraph in the list item.", 50..96) + ], + ),], + 0..96, + ),] + ); + } + + #[test] + fn test_list_with_leading_text() { + let parsed = parse( + "\ +* `code` +* **bold** +* [link](https://example.com) +", + ); + + assert_eq!( + parsed.children, + vec![list( + vec![ + list_item(1, Unordered, vec![p("code", 0..9)],), + list_item(1, Unordered, vec![p("bold", 9..20)]), + list_item(1, Unordered, vec![p("link", 20..50)],) + ], + 0..50, + ),] + ); + } + + #[test] + fn test_simple_block_quote() { + let parsed = parse("> Simple block quote with **styled text**"); + + assert_eq!( + parsed.children, + vec![block_quote( + vec![p("Simple block quote with styled text", 2..41)], + 0..41 + )] + ); + } + + #[test] + fn test_simple_block_quote_with_multiple_lines() { + let parsed = parse( + "\ +> # Heading +> More +> text +> +> More text +", + ); + + assert_eq!( + parsed.children, + vec![block_quote( + vec![ + h1(text("Heading", 2..12), 2..12), + p("More text", 14..26), + p("More text", 30..40) + ], + 0..40 + )] + ); + } + + #[test] + fn test_nested_block_quote() { + let parsed = parse( + "\ +> A +> +> > # B +> +> C + +More text +", + ); + + assert_eq!( + parsed.children, + vec![ + block_quote( + vec![ + p("A", 2..4), + block_quote(vec![h1(text("B", 10..14), 10..14)], 8..14), + p("C", 18..20) + ], + 0..20 + ), + p("More text", 21..31) + ] + ); + } + + #[test] + fn test_code_block() { + let parsed = parse( + "\ +``` +fn main() { + return 0; +} +``` +", + ); + + assert_eq!( + parsed.children, + vec![code_block(None, "fn main() {\n return 0;\n}", 0..35)] + ); + } + + #[test] + fn test_code_block_with_language() { + let parsed = parse( + "\ +```rust +fn main() { + return 0; +} +``` +", + ); + + assert_eq!( + parsed.children, + vec![code_block( + Some("rust".into()), + "fn main() {\n return 0;\n}", + 0..39 + )] + ); + } + + fn h1(contents: ParsedMarkdownText, source_range: Range) -> ParsedMarkdownElement { + ParsedMarkdownElement::Heading(ParsedMarkdownHeading { + source_range, + level: HeadingLevel::H1, + contents, + }) + } + + fn h2(contents: ParsedMarkdownText, source_range: Range) -> ParsedMarkdownElement { + ParsedMarkdownElement::Heading(ParsedMarkdownHeading { + source_range, + level: HeadingLevel::H2, + contents, + }) + } + + fn h3(contents: ParsedMarkdownText, source_range: Range) -> ParsedMarkdownElement { + ParsedMarkdownElement::Heading(ParsedMarkdownHeading { + source_range, + level: HeadingLevel::H3, + contents, + }) + } + + fn p(contents: &str, source_range: Range) -> ParsedMarkdownElement { + ParsedMarkdownElement::Paragraph(text(contents, source_range)) + } + + fn text(contents: &str, source_range: Range) -> ParsedMarkdownText { + ParsedMarkdownText { + highlights: Vec::new(), + region_ranges: Vec::new(), + regions: Vec::new(), + source_range, + contents: contents.to_string(), + } + } + + fn block_quote( + children: Vec, + source_range: Range, + ) -> ParsedMarkdownElement { + ParsedMarkdownElement::BlockQuote(ParsedMarkdownBlockQuote { + source_range, + children: children.into_iter().map(Box::new).collect(), + }) + } + + fn code_block( + language: Option, + code: &str, + source_range: Range, + ) -> ParsedMarkdownElement { + ParsedMarkdownElement::CodeBlock(ParsedMarkdownCodeBlock { + source_range, + language, + contents: code.to_string().into(), + }) + } + + fn list( + children: Vec, + source_range: Range, + ) -> ParsedMarkdownElement { + List(ParsedMarkdownList { + source_range, + children, + }) + } + + fn list_item( + depth: u16, + item_type: ParsedMarkdownListItemType, + contents: Vec, + ) -> ParsedMarkdownListItem { + ParsedMarkdownListItem { + item_type, + depth, + contents: contents.into_iter().map(Box::new).collect(), + } + } + + fn table( + source_range: Range, + header: ParsedMarkdownTableRow, + body: Vec, + ) -> ParsedMarkdownTable { + ParsedMarkdownTable { + column_alignments: Vec::new(), + source_range, + header, + body, + } + } + + fn row(children: Vec) -> ParsedMarkdownTableRow { + ParsedMarkdownTableRow { children } + } + + impl PartialEq for ParsedMarkdownTable { + fn eq(&self, other: &Self) -> bool { + self.source_range == other.source_range + && self.header == other.header + && self.body == other.body + } + } + + impl PartialEq for ParsedMarkdownText { + fn eq(&self, other: &Self) -> bool { + self.source_range == other.source_range && self.contents == other.contents + } + } +} diff --git a/crates/markdown_preview/src/markdown_preview.rs b/crates/markdown_preview/src/markdown_preview.rs index 84c8ac6245..e29f977d71 100644 --- a/crates/markdown_preview/src/markdown_preview.rs +++ b/crates/markdown_preview/src/markdown_preview.rs @@ -1,6 +1,8 @@ use gpui::{actions, AppContext}; use workspace::Workspace; +pub mod markdown_elements; +pub mod markdown_parser; pub mod markdown_preview_view; pub mod markdown_renderer; diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index f9e121e7ee..f22a997e49 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -1,35 +1,41 @@ +use std::{ops::Range, path::PathBuf}; + use editor::{Editor, EditorEvent}; use gpui::{ - canvas, AnyElement, AppContext, AvailableSpace, EventEmitter, FocusHandle, FocusableView, - InteractiveElement, IntoElement, ParentElement, Render, Styled, View, ViewContext, + list, AnyElement, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement, + IntoElement, ListState, ParentElement, Render, Styled, View, ViewContext, WeakView, }; -use language::LanguageRegistry; -use std::sync::Arc; use ui::prelude::*; use workspace::item::Item; use workspace::Workspace; -use crate::{markdown_renderer::render_markdown, OpenPreview}; +use crate::{ + markdown_elements::ParsedMarkdown, + markdown_parser::parse_markdown, + markdown_renderer::{render_markdown_block, RenderContext}, + OpenPreview, +}; pub struct MarkdownPreviewView { + workspace: WeakView, focus_handle: FocusHandle, - languages: Arc, - contents: String, + contents: ParsedMarkdown, + selected_block: usize, + list_state: ListState, } impl MarkdownPreviewView { pub fn register(workspace: &mut Workspace, _cx: &mut ViewContext) { - let languages = workspace.app_state().languages.clone(); - workspace.register_action(move |workspace, _: &OpenPreview, cx| { if workspace.has_active_modal(cx) { cx.propagate(); return; } - let languages = languages.clone(); + if let Some(editor) = workspace.active_item_as::(cx) { + let workspace_handle = workspace.weak_handle(); let view: View = - cx.new_view(|cx| MarkdownPreviewView::new(editor, languages, cx)); + MarkdownPreviewView::new(editor, workspace_handle, cx); workspace.split_item(workspace::SplitDirection::Right, Box::new(view.clone()), cx); cx.notify(); } @@ -38,30 +44,121 @@ impl MarkdownPreviewView { pub fn new( active_editor: View, - languages: Arc, - cx: &mut ViewContext, - ) -> Self { - let focus_handle = cx.focus_handle(); + workspace: WeakView, + cx: &mut ViewContext, + ) -> View { + cx.new_view(|cx: &mut ViewContext| { + let view = cx.view().downgrade(); + let editor = active_editor.read(cx); - cx.subscribe(&active_editor, |this, editor, event: &EditorEvent, cx| { - if *event == EditorEvent::Edited { - let editor = editor.read(cx); - let contents = editor.buffer().read(cx).snapshot(cx).text(); - this.contents = contents; - cx.notify(); + let file_location = MarkdownPreviewView::get_folder_for_active_editor(editor, cx); + let contents = editor.buffer().read(cx).snapshot(cx).text(); + let contents = parse_markdown(&contents, file_location); + + cx.subscribe(&active_editor, |this, editor, event: &EditorEvent, cx| { + match event { + EditorEvent::Edited => { + let editor = editor.read(cx); + let contents = editor.buffer().read(cx).snapshot(cx).text(); + let file_location = + MarkdownPreviewView::get_folder_for_active_editor(editor, cx); + this.contents = parse_markdown(&contents, file_location); + this.list_state.reset(this.contents.children.len()); + cx.notify(); + + // TODO: This does not work as expected. + // The scroll request appears to be dropped + // after `.reset` is called. + this.list_state.scroll_to_reveal_item(this.selected_block); + cx.notify(); + } + EditorEvent::SelectionsChanged { .. } => { + let editor = editor.read(cx); + let selection_range = editor.selections.last::(cx).range(); + this.selected_block = this.get_block_index_under_cursor(selection_range); + this.list_state.scroll_to_reveal_item(this.selected_block); + cx.notify(); + } + _ => {} + }; + }) + .detach(); + + let list_state = ListState::new( + contents.children.len(), + gpui::ListAlignment::Top, + px(1000.), + move |ix, cx| { + if let Some(view) = view.upgrade() { + view.update(cx, |view, cx| { + let mut render_cx = + RenderContext::new(Some(view.workspace.clone()), cx); + let block = view.contents.children.get(ix).unwrap(); + let block = render_markdown_block(block, &mut render_cx); + let block = div().child(block).pl_4().pb_3(); + + if ix == view.selected_block { + let indicator = div() + .h_full() + .w(px(4.0)) + .bg(cx.theme().colors().border) + .rounded_sm(); + + return div() + .relative() + .child(block) + .child(indicator.absolute().left_0().top_0()) + .into_any(); + } + + block.into_any() + }) + } else { + div().into_any() + } + }, + ); + + Self { + selected_block: 0, + focus_handle: cx.focus_handle(), + workspace, + contents, + list_state, } }) - .detach(); + } - let editor = active_editor.read(cx); - let contents = editor.buffer().read(cx).snapshot(cx).text(); - - Self { - focus_handle, - languages, - contents, + /// The absolute path of the file that is currently being previewed. + fn get_folder_for_active_editor( + editor: &Editor, + cx: &ViewContext, + ) -> Option { + if let Some(file) = editor.file_at(0, cx) { + if let Some(file) = file.as_local() { + file.abs_path(cx).parent().map(|p| p.to_path_buf()) + } else { + None + } + } else { + None } } + + fn get_block_index_under_cursor(&self, selection_range: Range) -> usize { + let mut block_index = 0; + let cursor = selection_range.start; + + for (i, block) in self.contents.children.iter().enumerate() { + let Range { start, end } = block.source_range(); + if start <= cursor && end >= cursor { + block_index = i; + break; + } + } + + return block_index; + } } impl FocusableView for MarkdownPreviewView { @@ -108,30 +205,17 @@ impl Item for MarkdownPreviewView { impl Render for MarkdownPreviewView { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let rendered_markdown = v_flex() - .items_start() - .justify_start() + v_flex() + .id("MarkdownPreview") .key_context("MarkdownPreview") .track_focus(&self.focus_handle) - .id("MarkdownPreview") - .overflow_y_scroll() - .overflow_x_hidden() - .size_full() + .full() .bg(cx.theme().colors().editor_background) .p_4() - .children(render_markdown(&self.contents, &self.languages, cx)); - - div().flex_1().child( - // FIXME: This shouldn't be necessary - // but the overflow_scroll above doesn't seem to work without it - canvas(move |bounds, cx| { - rendered_markdown.into_any().draw( - bounds.origin, - bounds.size.map(AvailableSpace::Definite), - cx, - ) - }) - .size_full(), - ) + .child( + div() + .flex_grow() + .map(|this| this.child(list(self.list_state.clone()).full())), + ) } } diff --git a/crates/markdown_preview/src/markdown_renderer.rs b/crates/markdown_preview/src/markdown_renderer.rs index 60fab49478..18e6cba18d 100644 --- a/crates/markdown_preview/src/markdown_renderer.rs +++ b/crates/markdown_preview/src/markdown_renderer.rs @@ -1,346 +1,322 @@ -use std::{ops::Range, sync::Arc}; - -use gpui::{ - div, px, rems, AnyElement, DefiniteLength, Div, ElementId, Hsla, ParentElement, SharedString, - Styled, StyledText, WindowContext, +use crate::markdown_elements::{ + HeadingLevel, Link, ParsedMarkdown, ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock, + ParsedMarkdownElement, ParsedMarkdownHeading, ParsedMarkdownList, ParsedMarkdownListItemType, + ParsedMarkdownTable, ParsedMarkdownTableAlignment, ParsedMarkdownTableRow, ParsedMarkdownText, }; -use language::LanguageRegistry; -use pulldown_cmark::{Alignment, CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag}; -use rich_text::render_rich_text; -use theme::{ActiveTheme, Theme}; -use ui::{h_flex, v_flex}; +use gpui::{ + div, px, rems, AbsoluteLength, AnyElement, DefiniteLength, Div, Element, ElementId, + HighlightStyle, Hsla, InteractiveText, IntoElement, ParentElement, SharedString, Styled, + StyledText, TextStyle, WeakView, WindowContext, +}; +use std::{ops::Range, sync::Arc}; +use theme::{ActiveTheme, SyntaxTheme}; +use ui::{h_flex, v_flex, Label}; +use workspace::Workspace; -enum TableState { - Header, - Body, -} - -struct MarkdownTable { - column_alignments: Vec, - header: Vec
, - body: Vec>, - current_row: Vec
, - state: TableState, +pub struct RenderContext { + workspace: Option>, + next_id: usize, + text_style: TextStyle, border_color: Hsla, + text_color: Hsla, + text_muted_color: Hsla, + code_block_background_color: Hsla, + code_span_background_color: Hsla, + syntax_theme: Arc, + indent: usize, } -impl MarkdownTable { - fn new(border_color: Hsla, column_alignments: Vec) -> Self { - Self { - column_alignments, - header: Vec::new(), - body: Vec::new(), - current_row: Vec::new(), - state: TableState::Header, - border_color, +impl RenderContext { + pub fn new(workspace: Option>, cx: &WindowContext) -> RenderContext { + let theme = cx.theme().clone(); + + RenderContext { + workspace, + next_id: 0, + indent: 0, + text_style: cx.text_style(), + syntax_theme: theme.syntax().clone(), + border_color: theme.colors().border, + text_color: theme.colors().text, + text_muted_color: theme.colors().text_muted, + code_block_background_color: theme.colors().surface_background, + code_span_background_color: theme.colors().editor_document_highlight_read_background, } } - fn finish_row(&mut self) { - match self.state { - TableState::Header => { - self.header.extend(self.current_row.drain(..)); - self.state = TableState::Body; - } - TableState::Body => { - self.body.push(self.current_row.drain(..).collect()); - } - } + fn next_id(&mut self, span: &Range) -> ElementId { + let id = format!("markdown-{}-{}-{}", self.next_id, span.start, span.end); + self.next_id += 1; + ElementId::from(SharedString::from(id)) } - fn add_cell(&mut self, contents: AnyElement) { - let container = match self.alignment_for_next_cell() { - Alignment::Left | Alignment::None => div(), - Alignment::Center => v_flex().items_center(), - Alignment::Right => v_flex().items_end(), + /// This ensures that children inside of block quotes + /// have padding between them. + /// + /// For example, for this markdown: + /// + /// ```markdown + /// > This is a block quote. + /// > + /// > And this is the next paragraph. + /// ``` + /// + /// We give padding between "This is a block quote." + /// and "And this is the next paragraph." + fn with_common_p(&self, element: Div) -> Div { + if self.indent > 0 { + element.pb_3() + } else { + element + } + } +} + +pub fn render_parsed_markdown( + parsed: &ParsedMarkdown, + workspace: Option>, + cx: &WindowContext, +) -> Vec { + let mut cx = RenderContext::new(workspace, cx); + let mut elements = Vec::new(); + + for child in &parsed.children { + elements.push(render_markdown_block(child, &mut cx)); + } + + return elements; +} + +pub fn render_markdown_block(block: &ParsedMarkdownElement, cx: &mut RenderContext) -> AnyElement { + use ParsedMarkdownElement::*; + match block { + Paragraph(text) => render_markdown_paragraph(text, cx), + Heading(heading) => render_markdown_heading(heading, cx), + List(list) => render_markdown_list(list, cx), + Table(table) => render_markdown_table(table, cx), + BlockQuote(block_quote) => render_markdown_block_quote(block_quote, cx), + CodeBlock(code_block) => render_markdown_code_block(code_block, cx), + HorizontalRule(_) => render_markdown_rule(cx), + } +} + +fn render_markdown_heading(parsed: &ParsedMarkdownHeading, cx: &mut RenderContext) -> AnyElement { + let size = match parsed.level { + HeadingLevel::H1 => rems(2.), + HeadingLevel::H2 => rems(1.5), + HeadingLevel::H3 => rems(1.25), + HeadingLevel::H4 => rems(1.), + HeadingLevel::H5 => rems(0.875), + HeadingLevel::H6 => rems(0.85), + }; + + let color = match parsed.level { + HeadingLevel::H6 => cx.text_muted_color, + _ => cx.text_color, + }; + + let line_height = DefiniteLength::from(rems(1.25)); + + div() + .line_height(line_height) + .text_size(size) + .text_color(color) + .pt(rems(0.15)) + .pb_1() + .child(render_markdown_text(&parsed.contents, cx)) + .into_any() +} + +fn render_markdown_list(parsed: &ParsedMarkdownList, cx: &mut RenderContext) -> AnyElement { + use ParsedMarkdownListItemType::*; + + let mut items = vec![]; + for item in &parsed.children { + let padding = rems((item.depth - 1) as f32 * 0.25); + + let bullet = match item.item_type { + Ordered(order) => format!("{}.", order), + Unordered => "•".to_string(), + Task(checked) => if checked { "☑" } else { "☐" }.to_string(), + }; + let bullet = div().mr_2().child(Label::new(bullet)); + + let contents: Vec = item + .contents + .iter() + .map(|c| render_markdown_block(c.as_ref(), cx)) + .collect(); + + let item = h_flex() + .pl(DefiniteLength::Absolute(AbsoluteLength::Rems(padding))) + .items_start() + .children(vec![bullet, div().children(contents).pr_2().w_full()]); + + items.push(item); + } + + cx.with_common_p(div()).children(items).into_any() +} + +fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -> AnyElement { + let header = render_markdown_table_row(&parsed.header, &parsed.column_alignments, true, cx); + + let body: Vec = parsed + .body + .iter() + .map(|row| render_markdown_table_row(row, &parsed.column_alignments, false, cx)) + .collect(); + + cx.with_common_p(v_flex()) + .w_full() + .child(header) + .children(body) + .into_any() +} + +fn render_markdown_table_row( + parsed: &ParsedMarkdownTableRow, + alignments: &Vec, + is_header: bool, + cx: &mut RenderContext, +) -> AnyElement { + let mut items = vec![]; + + for cell in &parsed.children { + let alignment = alignments + .get(items.len()) + .copied() + .unwrap_or(ParsedMarkdownTableAlignment::None); + + let contents = render_markdown_text(cell, cx); + + let container = match alignment { + ParsedMarkdownTableAlignment::Left | ParsedMarkdownTableAlignment::None => div(), + ParsedMarkdownTableAlignment::Center => v_flex().items_center(), + ParsedMarkdownTableAlignment::Right => v_flex().items_end(), }; - let cell = container + let mut cell = container .w_full() .child(contents) .px_2() .py_1() - .border_color(self.border_color); + .border_color(cx.border_color); - let cell = match self.state { - TableState::Header => cell.border_2(), - TableState::Body => cell.border_1(), - }; - - self.current_row.push(cell); - } - - fn finish(self) -> Div { - let mut table = v_flex().w_full(); - let mut header = h_flex(); - - for cell in self.header { - header = header.child(cell); + if is_header { + cell = cell.border_2() + } else { + cell = cell.border_1() } - table = table.child(header); - for row in self.body { - let mut row_div = h_flex(); - for cell in row { - row_div = row_div.child(cell); - } - table = table.child(row_div); - } - table + + items.push(cell); } - fn alignment_for_next_cell(&self) -> Alignment { - self.column_alignments - .get(self.current_row.len()) - .copied() - .unwrap_or(Alignment::None) - } + h_flex().children(items).into_any_element() } -struct Renderer { - source_contents: String, - iter: I, - theme: Arc, - finished: Vec
, - language_registry: Arc, - table: Option, - list_depth: usize, - block_quote_depth: usize, +fn render_markdown_block_quote( + parsed: &ParsedMarkdownBlockQuote, + cx: &mut RenderContext, +) -> AnyElement { + cx.indent += 1; + + let children: Vec = parsed + .children + .iter() + .map(|child| render_markdown_block(child, cx)) + .collect(); + + cx.indent -= 1; + + cx.with_common_p(div()) + .child( + div() + .border_l_4() + .border_color(cx.border_color) + .pl_3() + .children(children), + ) + .into_any() } -impl<'a, I> Renderer -where - I: Iterator, Range)>, -{ - fn new( - iter: I, - source_contents: String, - language_registry: &Arc, - theme: Arc, - ) -> Self { - Self { - iter, - source_contents, - theme, - table: None, - finished: vec![], - language_registry: language_registry.clone(), - list_depth: 0, - block_quote_depth: 0, - } - } - - fn run(mut self, cx: &WindowContext) -> Self { - while let Some((event, source_range)) = self.iter.next() { - match event { - Event::Start(tag) => { - self.start_tag(tag); - } - Event::End(tag) => { - self.end_tag(tag, source_range, cx); - } - Event::Rule => { - let rule = div().w_full().h(px(2.)).bg(self.theme.colors().border); - self.finished.push(div().mb_4().child(rule)); - } - _ => {} - } - } - self - } - - fn start_tag(&mut self, tag: Tag<'a>) { - match tag { - Tag::List(_) => { - self.list_depth += 1; - } - Tag::BlockQuote => { - self.block_quote_depth += 1; - } - Tag::Table(column_alignments) => { - self.table = Some(MarkdownTable::new( - self.theme.colors().border, - column_alignments, - )); - } - _ => {} - } - } - - fn end_tag(&mut self, tag: Tag, source_range: Range, cx: &WindowContext) { - match tag { - Tag::Paragraph => { - if self.list_depth > 0 || self.block_quote_depth > 0 { - return; - } - - let element = self.render_md_from_range(source_range.clone(), cx); - let paragraph = div().mb_3().child(element); - - self.finished.push(paragraph); - } - Tag::Heading(level, _, _) => { - let mut headline = self.headline(level); - if source_range.start > 0 { - headline = headline.mt_4(); - } - - let element = self.render_md_from_range(source_range.clone(), cx); - let headline = headline.child(element); - - self.finished.push(headline); - } - Tag::List(_) => { - if self.list_depth == 1 { - let element = self.render_md_from_range(source_range.clone(), cx); - let list = div().mb_3().child(element); - - self.finished.push(list); - } - - self.list_depth -= 1; - } - Tag::BlockQuote => { - let element = self.render_md_from_range(source_range.clone(), cx); - - let block_quote = h_flex() - .mb_3() - .child( - div() - .w(px(4.)) - .bg(self.theme.colors().border) - .h_full() - .mr_2() - .mt_1(), - ) - .text_color(self.theme.colors().text_muted) - .child(element); - - self.finished.push(block_quote); - - self.block_quote_depth -= 1; - } - Tag::CodeBlock(kind) => { - let contents = self.source_contents[source_range.clone()].trim(); - let contents = contents.trim_start_matches("```"); - let contents = contents.trim_end_matches("```"); - let contents = match kind { - CodeBlockKind::Fenced(language) => { - contents.trim_start_matches(&language.to_string()) - } - CodeBlockKind::Indented => contents, - }; - let contents: String = contents.into(); - let contents = SharedString::from(contents); - - let code_block = div() - .mb_3() - .px_4() - .py_0() - .bg(self.theme.colors().surface_background) - .child(StyledText::new(contents)); - - self.finished.push(code_block); - } - Tag::Table(_alignment) => { - if self.table.is_none() { - log::error!("Table end without table ({:?})", source_range); - return; - } - - let table = self.table.take().unwrap(); - let table = table.finish().mb_4(); - self.finished.push(table); - } - Tag::TableHead => { - if self.table.is_none() { - log::error!("Table head without table ({:?})", source_range); - return; - } - - self.table.as_mut().unwrap().finish_row(); - } - Tag::TableRow => { - if self.table.is_none() { - log::error!("Table row without table ({:?})", source_range); - return; - } - - self.table.as_mut().unwrap().finish_row(); - } - Tag::TableCell => { - if self.table.is_none() { - log::error!("Table cell without table ({:?})", source_range); - return; - } - - let contents = self.render_md_from_range(source_range.clone(), cx); - self.table.as_mut().unwrap().add_cell(contents); - } - _ => {} - } - } - - fn render_md_from_range( - &self, - source_range: Range, - cx: &WindowContext, - ) -> gpui::AnyElement { - let mentions = &[]; - let language = None; - let paragraph = &self.source_contents[source_range.clone()]; - let rich_text = render_rich_text( - paragraph.into(), - mentions, - &self.language_registry, - language, - ); - let id: ElementId = source_range.start.into(); - rich_text.element(id, cx) - } - - fn headline(&self, level: HeadingLevel) -> Div { - let size = match level { - HeadingLevel::H1 => rems(2.), - HeadingLevel::H2 => rems(1.5), - HeadingLevel::H3 => rems(1.25), - HeadingLevel::H4 => rems(1.), - HeadingLevel::H5 => rems(0.875), - HeadingLevel::H6 => rems(0.85), - }; - - let color = match level { - HeadingLevel::H6 => self.theme.colors().text_muted, - _ => self.theme.colors().text, - }; - - let line_height = DefiniteLength::from(rems(1.25)); - - let headline = h_flex() - .w_full() - .line_height(line_height) - .text_size(size) - .text_color(color) - .mb_4() - .pb(rems(0.15)); - - headline - } +fn render_markdown_code_block( + parsed: &ParsedMarkdownCodeBlock, + cx: &mut RenderContext, +) -> AnyElement { + cx.with_common_p(div()) + .px_3() + .py_3() + .bg(cx.code_block_background_color) + .child(StyledText::new(parsed.contents.clone())) + .into_any() } -pub fn render_markdown( - markdown_input: &str, - language_registry: &Arc, - cx: &WindowContext, -) -> Vec
{ - let theme = cx.theme().clone(); - let options = Options::all(); - let parser = Parser::new_ext(markdown_input, options); - let renderer = Renderer::new( - parser.into_offset_iter(), - markdown_input.to_owned(), - language_registry, - theme, +fn render_markdown_paragraph(parsed: &ParsedMarkdownText, cx: &mut RenderContext) -> AnyElement { + cx.with_common_p(div()) + .child(render_markdown_text(parsed, cx)) + .into_any_element() +} + +fn render_markdown_text(parsed: &ParsedMarkdownText, cx: &mut RenderContext) -> AnyElement { + let element_id = cx.next_id(&parsed.source_range); + + let highlights = gpui::combine_highlights( + parsed.highlights.iter().filter_map(|(range, highlight)| { + let highlight = highlight.to_highlight_style(&cx.syntax_theme)?; + Some((range.clone(), highlight)) + }), + parsed + .regions + .iter() + .zip(&parsed.region_ranges) + .filter_map(|(region, range)| { + if region.code { + Some(( + range.clone(), + HighlightStyle { + background_color: Some(cx.code_span_background_color), + ..Default::default() + }, + )) + } else { + None + } + }), ); - let renderer = renderer.run(cx); - return renderer.finished; + + let mut links = Vec::new(); + let mut link_ranges = Vec::new(); + for (range, region) in parsed.region_ranges.iter().zip(&parsed.regions) { + if let Some(link) = region.link.clone() { + links.push(link); + link_ranges.push(range.clone()); + } + } + + let workspace = cx.workspace.clone(); + + InteractiveText::new( + element_id, + StyledText::new(parsed.contents.clone()).with_highlights(&cx.text_style, highlights), + ) + .on_click( + link_ranges, + move |clicked_range_ix, window_cx| match &links[clicked_range_ix] { + Link::Web { url } => window_cx.open_url(url), + Link::Path { path } => { + if let Some(workspace) = &workspace { + _ = workspace.update(window_cx, |workspace, cx| { + workspace.open_abs_path(path.clone(), false, cx).detach(); + }); + } + } + }, + ) + .into_any_element() +} + +fn render_markdown_rule(cx: &mut RenderContext) -> AnyElement { + let rule = div().w_full().h(px(2.)).bg(cx.border_color); + div().pt_3().pb_3().child(rule).into_any() }