Cargo.lock 🔗
@@ -4527,9 +4527,9 @@ dependencies = [
"lazy_static",
"log",
"menu",
+ "pretty_assertions",
"project",
"pulldown-cmark",
- "rich_text",
"theme",
"ui",
"util",
Kieran Gill created
This PR improves support for rendering markdown documents.
## After the updates
https://github.com/zed-industries/zed/assets/18583882/48315901-563d-44c6-8265-8390e8eed942
## Before the updates
https://github.com/zed-industries/zed/assets/18583882/6d7ddb55-41f7-492e-af12-6ab54559f612
## New features
- @SomeoneToIgnore's [scrolling feature
request](https://github.com/zed-industries/zed/pull/6958#pullrequestreview-1850458632).
- Checkboxes (`- [ ]` and `- [x]`)
- Inline code blocks.
- Ordered and unordered lists at an arbitrary depth.
- Block quotes that render nested content, like code blocks.
- Lists that render nested content, like code blocks.
- Block quotes that support variable heading sizes and the other
markdown features added
[here](https://github.com/zed-industries/zed/pull/6958).
- Users can see and click internal links (`[See the docs](./docs.md)`).
## Notable changes
- Removed dependency on `rich_text`.
- Added a new method for parsing markdown into renderable structs. This
method uses recursive descent so it can easily support more complex
markdown documents.
- Parsing does not happen for every call to
`MarkdownPreviewView::render` anymore.
## TODO
- [ ] Typing should move the markdown preview cursor.
## Future work under consideration
- If a title exists for a link, show it on hover.
- Images.
- Since this PR brings the most support for markdown, we can consolidate
`languages/markdown` and `rich_text` to use this new renderer. Note that
the updated inline text rendering method in this PR originated from
`langauges/markdown`.
- Syntax highlighting in code blocks.
- Footnote references.
- Inline HTML.
- Strikethrough support.
- Scrolling improvements:
- Handle automatic preview scrolling when multiple cursors are used in
the editor.
- > great to see that the render now respects editor's scrolls, but can
we also support the vice-versa (as syntax tree does it in Zed) — when
scrolling the render, it would be good to scroll the editor too
- > sometimes it's hard to understand where the "caret" on the render
is, so I wonder if we could go even further with its placement and place
it inside the text, as a regular caret? Maybe even support the
selections?
- > switching to another markdown tab does not change the rendered
contents and when I call the render command again, the screen gets
another split — I would rather prefer to have Zed's syntax tree
behavior: there's always a single panel that renders things for whatever
tab is active now. At least we should not split if there's already a
split, rather adding the new rendered tab there.
- > plaintext URLs could get a highlight and the click action
## Release Notes
- Improved support for markdown rendering.
Cargo.lock | 2
crates/markdown_preview/Cargo.toml | 2
crates/markdown_preview/src/markdown_elements.rs | 242 +++
crates/markdown_preview/src/markdown_parser.rs | 1110 +++++++++++++
crates/markdown_preview/src/markdown_preview.rs | 2
crates/markdown_preview/src/markdown_preview_view.rs | 184 +
crates/markdown_preview/src/markdown_renderer.rs | 578 +++---
7 files changed, 1,767 insertions(+), 353 deletions(-)
@@ -4527,9 +4527,9 @@ dependencies = [
"lazy_static",
"log",
"menu",
+ "pretty_assertions",
"project",
"pulldown-cmark",
- "rich_text",
"theme",
"ui",
"util",
@@ -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
@@ -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<usize>),
+}
+
+impl ParsedMarkdownElement {
+ pub fn source_range(&self) -> Range<usize> {
+ 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<ParsedMarkdownElement>,
+}
+
+#[derive(Debug)]
+#[cfg_attr(test, derive(PartialEq))]
+pub struct ParsedMarkdownList {
+ pub source_range: Range<usize>,
+ pub children: Vec<ParsedMarkdownListItem>,
+}
+
+#[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<Box<ParsedMarkdownElement>>,
+}
+
+#[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<usize>,
+ pub language: Option<String>,
+ pub contents: SharedString,
+}
+
+#[derive(Debug)]
+#[cfg_attr(test, derive(PartialEq))]
+pub struct ParsedMarkdownHeading {
+ pub source_range: Range<usize>,
+ 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<usize>,
+ pub header: ParsedMarkdownTableRow,
+ pub body: Vec<ParsedMarkdownTableRow>,
+ pub column_alignments: Vec<ParsedMarkdownTableAlignment>,
+}
+
+#[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<ParsedMarkdownText>,
+}
+
+impl ParsedMarkdownTableRow {
+ pub fn new() -> Self {
+ Self {
+ children: Vec::new(),
+ }
+ }
+
+ pub fn with_children(children: Vec<ParsedMarkdownText>) -> Self {
+ Self { children }
+ }
+}
+
+#[derive(Debug)]
+#[cfg_attr(test, derive(PartialEq))]
+pub struct ParsedMarkdownBlockQuote {
+ pub source_range: Range<usize>,
+ pub children: Vec<Box<ParsedMarkdownElement>>,
+}
+
+#[derive(Debug)]
+pub struct ParsedMarkdownText {
+ /// Where the text is located in the source Markdown document.
+ pub source_range: Range<usize>,
+ /// The text content stripped of any formatting symbols.
+ pub contents: String,
+ /// The list of highlights contained in the Markdown document.
+ pub highlights: Vec<(Range<usize>, MarkdownHighlight)>,
+ /// The regions of the various ranges in the Markdown document.
+ pub region_ranges: Vec<Range<usize>>,
+ /// The regions of the Markdown document.
+ pub regions: Vec<ParsedRegion>,
+}
+
+/// 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<HighlightStyle> {
+ 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<Link>,
+}
+
+/// 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<PathBuf>, text: String) -> Option<Link> {
+ 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
+ }
+}
@@ -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<PathBuf>,
+) -> 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<usize>)>,
+ /// The current index in the tokens array
+ cursor: usize,
+ /// The blocks that we have successfully parsed so far
+ parsed: Vec<ParsedMarkdownElement>,
+ file_location_directory: Option<PathBuf>,
+}
+
+impl<'a> MarkdownParser<'a> {
+ fn new(
+ tokens: Vec<(Event<'a>, Range<usize>)>,
+ file_location_directory: Option<PathBuf>,
+ ) -> 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<usize>)> {
+ 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<usize>)> {
+ if self.cursor == 0 || self.cursor > self.tokens.len() {
+ return None;
+ }
+ return self.tokens.get(self.cursor - 1);
+ }
+
+ fn current(&self) -> Option<&(Event, Range<usize>)> {
+ 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<ParsedMarkdownElement> {
+ 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<Link> = None;
+ let mut region_ranges: Vec<Range<usize>> = vec![];
+ let mut regions: Vec<ParsedRegion> = vec![];
+ let mut highlights: Vec<(Range<usize>, 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<ParsedMarkdownTableAlignment> = 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<u64>) -> 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<Box<ParsedMarkdownElement>> = 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<Box<ParsedMarkdownElement>> = 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<String>) -> 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<usize>) -> ParsedMarkdownElement {
+ ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
+ source_range,
+ level: HeadingLevel::H1,
+ contents,
+ })
+ }
+
+ fn h2(contents: ParsedMarkdownText, source_range: Range<usize>) -> ParsedMarkdownElement {
+ ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
+ source_range,
+ level: HeadingLevel::H2,
+ contents,
+ })
+ }
+
+ fn h3(contents: ParsedMarkdownText, source_range: Range<usize>) -> ParsedMarkdownElement {
+ ParsedMarkdownElement::Heading(ParsedMarkdownHeading {
+ source_range,
+ level: HeadingLevel::H3,
+ contents,
+ })
+ }
+
+ fn p(contents: &str, source_range: Range<usize>) -> ParsedMarkdownElement {
+ ParsedMarkdownElement::Paragraph(text(contents, source_range))
+ }
+
+ fn text(contents: &str, source_range: Range<usize>) -> ParsedMarkdownText {
+ ParsedMarkdownText {
+ highlights: Vec::new(),
+ region_ranges: Vec::new(),
+ regions: Vec::new(),
+ source_range,
+ contents: contents.to_string(),
+ }
+ }
+
+ fn block_quote(
+ children: Vec<ParsedMarkdownElement>,
+ source_range: Range<usize>,
+ ) -> ParsedMarkdownElement {
+ ParsedMarkdownElement::BlockQuote(ParsedMarkdownBlockQuote {
+ source_range,
+ children: children.into_iter().map(Box::new).collect(),
+ })
+ }
+
+ fn code_block(
+ language: Option<String>,
+ code: &str,
+ source_range: Range<usize>,
+ ) -> ParsedMarkdownElement {
+ ParsedMarkdownElement::CodeBlock(ParsedMarkdownCodeBlock {
+ source_range,
+ language,
+ contents: code.to_string().into(),
+ })
+ }
+
+ fn list(
+ children: Vec<ParsedMarkdownListItem>,
+ source_range: Range<usize>,
+ ) -> ParsedMarkdownElement {
+ List(ParsedMarkdownList {
+ source_range,
+ children,
+ })
+ }
+
+ fn list_item(
+ depth: u16,
+ item_type: ParsedMarkdownListItemType,
+ contents: Vec<ParsedMarkdownElement>,
+ ) -> ParsedMarkdownListItem {
+ ParsedMarkdownListItem {
+ item_type,
+ depth,
+ contents: contents.into_iter().map(Box::new).collect(),
+ }
+ }
+
+ fn table(
+ source_range: Range<usize>,
+ header: ParsedMarkdownTableRow,
+ body: Vec<ParsedMarkdownTableRow>,
+ ) -> ParsedMarkdownTable {
+ ParsedMarkdownTable {
+ column_alignments: Vec::new(),
+ source_range,
+ header,
+ body,
+ }
+ }
+
+ fn row(children: Vec<ParsedMarkdownText>) -> 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
+ }
+ }
+}
@@ -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;
@@ -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<Workspace>,
focus_handle: FocusHandle,
- languages: Arc<LanguageRegistry>,
- contents: String,
+ contents: ParsedMarkdown,
+ selected_block: usize,
+ list_state: ListState,
}
impl MarkdownPreviewView {
pub fn register(workspace: &mut Workspace, _cx: &mut ViewContext<Workspace>) {
- 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::<Editor>(cx) {
+ let workspace_handle = workspace.weak_handle();
let view: View<MarkdownPreviewView> =
- 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,29 +44,120 @@ impl MarkdownPreviewView {
pub fn new(
active_editor: View<Editor>,
- languages: Arc<LanguageRegistry>,
- cx: &mut ViewContext<Self>,
- ) -> Self {
- let focus_handle = cx.focus_handle();
-
- 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();
+ workspace: WeakView<Workspace>,
+ cx: &mut ViewContext<Workspace>,
+ ) -> View<Self> {
+ cx.new_view(|cx: &mut ViewContext<Self>| {
+ let view = cx.view().downgrade();
+ let editor = active_editor.read(cx);
+
+ 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::<usize>(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();
+ /// The absolute path of the file that is currently being previewed.
+ fn get_folder_for_active_editor(
+ editor: &Editor,
+ cx: &ViewContext<MarkdownPreviewView>,
+ ) -> Option<PathBuf> {
+ 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
+ }
+ }
- Self {
- focus_handle,
- languages,
- contents,
+ fn get_block_index_under_cursor(&self, selection_range: Range<usize>) -> 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;
}
}
@@ -108,30 +205,17 @@ impl Item for MarkdownPreviewView {
impl Render for MarkdownPreviewView {
fn render(&mut self, cx: &mut ViewContext<Self>) -> 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())),
+ )
}
}
@@ -1,346 +1,322 @@
-use std::{ops::Range, sync::Arc};
-
+use crate::markdown_elements::{
+ HeadingLevel, Link, ParsedMarkdown, ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock,
+ ParsedMarkdownElement, ParsedMarkdownHeading, ParsedMarkdownList, ParsedMarkdownListItemType,
+ ParsedMarkdownTable, ParsedMarkdownTableAlignment, ParsedMarkdownTableRow, ParsedMarkdownText,
+};
use gpui::{
- div, px, rems, AnyElement, DefiniteLength, Div, ElementId, Hsla, ParentElement, SharedString,
- Styled, StyledText, WindowContext,
+ div, px, rems, AbsoluteLength, AnyElement, DefiniteLength, Div, Element, ElementId,
+ HighlightStyle, Hsla, InteractiveText, IntoElement, ParentElement, SharedString, Styled,
+ StyledText, TextStyle, WeakView, WindowContext,
};
-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};
-
-enum TableState {
- Header,
- Body,
-}
-
-struct MarkdownTable {
- column_alignments: Vec<Alignment>,
- header: Vec<Div>,
- body: Vec<Vec<Div>>,
- current_row: Vec<Div>,
- state: TableState,
+use std::{ops::Range, sync::Arc};
+use theme::{ActiveTheme, SyntaxTheme};
+use ui::{h_flex, v_flex, Label};
+use workspace::Workspace;
+
+pub struct RenderContext {
+ workspace: Option<WeakView<Workspace>>,
+ 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<SyntaxTheme>,
+ indent: usize,
}
-impl MarkdownTable {
- fn new(border_color: Hsla, column_alignments: Vec<Alignment>) -> 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<WeakView<Workspace>>, 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<usize>) -> 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(),
- };
-
- let cell = container
- .w_full()
- .child(contents)
- .px_2()
- .py_1()
- .border_color(self.border_color);
-
- let cell = match self.state {
- TableState::Header => cell.border_2(),
- TableState::Body => cell.border_1(),
- };
-
- self.current_row.push(cell);
+ /// 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
+ }
}
+}
- fn finish(self) -> Div {
- let mut table = v_flex().w_full();
- let mut header = h_flex();
+pub fn render_parsed_markdown(
+ parsed: &ParsedMarkdown,
+ workspace: Option<WeakView<Workspace>>,
+ cx: &WindowContext,
+) -> Vec<AnyElement> {
+ let mut cx = RenderContext::new(workspace, cx);
+ let mut elements = Vec::new();
- for cell in self.header {
- header = header.child(cell);
- }
- 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
+ for child in &parsed.children {
+ elements.push(render_markdown_block(child, &mut cx));
}
- fn alignment_for_next_cell(&self) -> Alignment {
- self.column_alignments
- .get(self.current_row.len())
- .copied()
- .unwrap_or(Alignment::None)
+ 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),
}
}
-struct Renderer<I> {
- source_contents: String,
- iter: I,
- theme: Arc<Theme>,
- finished: Vec<Div>,
- language_registry: Arc<LanguageRegistry>,
- table: Option<MarkdownTable>,
- list_depth: usize,
- block_quote_depth: usize,
+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()
}
-impl<'a, I> Renderer<I>
-where
- I: Iterator<Item = (Event<'a>, Range<usize>)>,
-{
- fn new(
- iter: I,
- source_contents: String,
- language_registry: &Arc<LanguageRegistry>,
- theme: Arc<Theme>,
- ) -> Self {
- Self {
- iter,
- source_contents,
- theme,
- table: None,
- finished: vec![],
- language_registry: language_registry.clone(),
- list_depth: 0,
- block_quote_depth: 0,
- }
- }
+fn render_markdown_list(parsed: &ParsedMarkdownList, cx: &mut RenderContext) -> AnyElement {
+ use ParsedMarkdownListItemType::*;
- 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
- }
+ let mut items = vec![];
+ for item in &parsed.children {
+ let padding = rems((item.depth - 1) as f32 * 0.25);
- 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,
- ));
- }
- _ => {}
- }
- }
+ 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));
- fn end_tag(&mut self, tag: Tag, source_range: Range<usize>, cx: &WindowContext) {
- match tag {
- Tag::Paragraph => {
- if self.list_depth > 0 || self.block_quote_depth > 0 {
- return;
- }
+ let contents: Vec<AnyElement> = item
+ .contents
+ .iter()
+ .map(|c| render_markdown_block(c.as_ref(), cx))
+ .collect();
- let element = self.render_md_from_range(source_range.clone(), cx);
- let paragraph = div().mb_3().child(element);
+ let item = h_flex()
+ .pl(DefiniteLength::Absolute(AbsoluteLength::Rems(padding)))
+ .items_start()
+ .children(vec![bullet, div().children(contents).pr_2().w_full()]);
- self.finished.push(paragraph);
- }
- Tag::Heading(level, _, _) => {
- let mut headline = self.headline(level);
- if source_range.start > 0 {
- headline = headline.mt_4();
- }
+ items.push(item);
+ }
- let element = self.render_md_from_range(source_range.clone(), cx);
- let headline = headline.child(element);
+ cx.with_common_p(div()).children(items).into_any()
+}
- 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);
+fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -> AnyElement {
+ let header = render_markdown_table_row(&parsed.header, &parsed.column_alignments, true, cx);
- self.finished.push(list);
- }
+ let body: Vec<AnyElement> = parsed
+ .body
+ .iter()
+ .map(|row| render_markdown_table_row(row, &parsed.column_alignments, false, cx))
+ .collect();
- 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;
- }
+ cx.with_common_p(v_flex())
+ .w_full()
+ .child(header)
+ .children(body)
+ .into_any()
+}
- 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;
- }
+fn render_markdown_table_row(
+ parsed: &ParsedMarkdownTableRow,
+ alignments: &Vec<ParsedMarkdownTableAlignment>,
+ 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);
- self.table.as_mut().unwrap().finish_row();
- }
- Tag::TableRow => {
- if self.table.is_none() {
- log::error!("Table row without table ({:?})", source_range);
- return;
- }
+ let contents = render_markdown_text(cell, cx);
- self.table.as_mut().unwrap().finish_row();
- }
- Tag::TableCell => {
- if self.table.is_none() {
- log::error!("Table cell without table ({:?})", source_range);
- return;
- }
+ let container = match alignment {
+ ParsedMarkdownTableAlignment::Left | ParsedMarkdownTableAlignment::None => div(),
+ ParsedMarkdownTableAlignment::Center => v_flex().items_center(),
+ ParsedMarkdownTableAlignment::Right => v_flex().items_end(),
+ };
- let contents = self.render_md_from_range(source_range.clone(), cx);
- self.table.as_mut().unwrap().add_cell(contents);
- }
- _ => {}
+ let mut cell = container
+ .w_full()
+ .child(contents)
+ .px_2()
+ .py_1()
+ .border_color(cx.border_color);
+
+ if is_header {
+ cell = cell.border_2()
+ } else {
+ cell = cell.border_1()
}
- }
- fn render_md_from_range(
- &self,
- source_range: Range<usize>,
- 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)
+ items.push(cell);
}
- 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),
- };
+ h_flex().children(items).into_any_element()
+}
- let color = match level {
- HeadingLevel::H6 => self.theme.colors().text_muted,
- _ => self.theme.colors().text,
- };
+fn render_markdown_block_quote(
+ parsed: &ParsedMarkdownBlockQuote,
+ cx: &mut RenderContext,
+) -> AnyElement {
+ cx.indent += 1;
+
+ let children: Vec<AnyElement> = 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()
+}
- let line_height = DefiniteLength::from(rems(1.25));
+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()
+}
- let headline = h_flex()
- .w_full()
- .line_height(line_height)
- .text_size(size)
- .text_color(color)
- .mb_4()
- .pb(rems(0.15));
+fn render_markdown_paragraph(parsed: &ParsedMarkdownText, cx: &mut RenderContext) -> AnyElement {
+ cx.with_common_p(div())
+ .child(render_markdown_text(parsed, cx))
+ .into_any_element()
+}
- headline
+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 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()
}
-pub fn render_markdown(
- markdown_input: &str,
- language_registry: &Arc<LanguageRegistry>,
- cx: &WindowContext,
-) -> Vec<Div> {
- 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,
- );
- let renderer = renderer.run(cx);
- return renderer.finished;
+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()
}