use std::rc::Rc; use gpui::{ AnyElement, App, ElementId, FontStyle, FontWeight, HighlightStyle, InteractiveText, IntoElement, ParentElement, RenderOnce, SharedString, StrikethroughStyle, Styled, StyledText, UnderlineStyle, Window, div, prelude::*, }; use pulldown_cmark::Alignment; use super::tree::{Inline, Node, Style, parse}; use crate::{ editor::code_colors, primitives::{Icon, IconName}, theme::{ActiveTheme, IconSize, Radius, TextSize}, typography::Latex, }; type OnLink = Rc; /// Markdown as a document: headings, prose with its styles and links, lists and tasks, quotes, code colored as code, tables, rules, math and footnotes. A press on a link opens it, or hands it to the host. #[derive(IntoElement)] pub struct MarkdownRenderer { id: ElementId, source: SharedString, on_link: Option, } impl MarkdownRenderer { pub fn new(id: impl Into, source: impl Into) -> Self { Self { id: id.into(), source: source.into(), on_link: None, } } /// Takes link presses instead of the browser. pub fn on_link(mut self, handler: impl Fn(&str, &mut Window, &mut App) + 'static) -> Self { self.on_link = Some(Rc::new(handler)); self } } /// Renders nodes with one set of links handlers and ids under one owner. struct Draw<'a> { id: &'a ElementId, on_link: &'a Option, next: std::cell::Cell, } impl Draw<'_> { fn key(&self, what: &str) -> ElementId { let at = self.next.get(); self.next.set(at + 1); (self.id.clone(), format!("{what}-{at}")).into() } fn inline(&self, inline: &Inline, cx: &App) -> AnyElement { let theme = cx.theme(); let colors = &theme.colors; let rule = theme.underline_thickness(); let styles: Vec<_> = inline .styles .iter() .map(|(range, style)| { let highlight = match style { Style::Strong => HighlightStyle { font_weight: Some(FontWeight::SEMIBOLD), ..HighlightStyle::default() }, Style::Emphasis => HighlightStyle { font_style: Some(FontStyle::Italic), ..HighlightStyle::default() }, Style::Strike => HighlightStyle { strikethrough: Some(StrikethroughStyle { thickness: rule, color: Some(colors.fg_muted), }), ..HighlightStyle::default() }, Style::Code | Style::Math => HighlightStyle { background_color: Some(colors.sunken), color: Some(if *style == Style::Math { colors.syntax.function } else { colors.fg }), ..HighlightStyle::default() }, Style::Link => HighlightStyle { color: Some(colors.link), underline: Some(UnderlineStyle { thickness: rule, color: Some(colors.link.opacity(0.4)), wavy: false, }), ..HighlightStyle::default() }, Style::Footnote => HighlightStyle { color: Some(colors.fg_subtle), ..HighlightStyle::default() }, }; (range.clone(), highlight) }) .collect(); let styled = StyledText::new(inline.text.clone()).with_highlights(crate::editor::stack(styles)); if inline.links.is_empty() { return styled.into_any_element(); } let (ranges, urls): (Vec<_>, Vec<_>) = inline.links.iter().cloned().unzip(); let on_link = self.on_link.clone(); InteractiveText::new(self.key("links"), styled) .on_click(ranges, move |ix, window, cx| { let url = &urls[ix]; log::info!("markdown: open {url}"); match &on_link { Some(on_link) => on_link(url, window, cx), None => cx.open_url(url), } }) .into_any_element() } fn nodes(&self, nodes: &[Node], cx: &App) -> Vec { nodes.iter().map(|node| self.node(node, cx)).collect() } fn node(&self, node: &Node, cx: &App) -> AnyElement { let theme = cx.theme(); let colors = theme.colors.clone(); match node { Node::Paragraph(inline) => div() .text_color(colors.fg) .child(self.inline(inline, cx)) .into_any_element(), Node::Heading(level, inline) => { let size = match level { 1 => TextSize::Xxl, 2 => TextSize::Xl, 3 => TextSize::Lg, _ => TextSize::Md, }; div() .pt_2() .text_size(theme.text_size(size)) .font_weight(if *level <= 2 { FontWeight::SEMIBOLD } else { FontWeight::MEDIUM }) .text_color(colors.fg) .child(self.inline(inline, cx)) .into_any_element() } Node::Quote(inner) => div() .flex() .flex_col() .gap_2() .pl_4() .border_l_2() .border_color(colors.border_strong) .text_color(colors.fg_muted) .children(self.nodes(inner, cx)) .into_any_element(), Node::Code { language, text } => { let styles = code_colors(text, cx); div() .flex() .flex_col() .gap_1() .p_3() .rounded(theme.radius(Radius::Md)) .bg(colors.sunken) .font_family(theme.mono_family.clone()) .text_size(theme.text_size(TextSize::Sm)) .text_color(colors.syntax.variable) .children(language.clone().map(|name| { div() .text_size(theme.text_size(TextSize::Xs)) .text_color(colors.fg_subtle) .child(name) })) .child(StyledText::new(text.clone()).with_highlights(styles)) .into_any_element() } Node::List { start, items } => div() .flex() .flex_col() .gap_1() .children(items.iter().enumerate().map(|(ix, (task, body))| { let marker: AnyElement = match (task, start) { (Some(done), _) => Icon::new(if *done { IconName::SquareCheck } else { IconName::Square }) .size(IconSize::Sm) .color(if *done { colors.focus } else { colors.fg_subtle }) .into_any_element(), (None, Some(first)) => div() .text_color(colors.fg_muted) .child(format!("{}.", first + ix as u64)) .into_any_element(), (None, None) => div() .text_color(colors.fg_muted) .child("•") .into_any_element(), }; div() .flex() .gap_2() .child( div() .flex_none() .min_w_5() .flex() .justify_end() .child(marker), ) .child( div() .flex_1() .min_w_0() .flex() .flex_col() .gap_1() .when(*task == Some(true), |body| { body.text_color(colors.fg_subtle) }) .children(self.nodes(body, cx)), ) })) .into_any_element(), Node::Table { aligns, head, rows } => { let cell = |inline: &Inline, column: usize, header: bool, cx: &App| { div() .flex_1() .flex() .px_3() .py_1p5() .when(aligns.get(column) == Some(&Alignment::Right), |cell| { cell.justify_end() }) .when(aligns.get(column) == Some(&Alignment::Center), |cell| { cell.justify_center() }) .when(header, |cell| { cell.font_weight(FontWeight::MEDIUM) .text_color(colors.fg_muted) }) .child(self.inline(inline, cx)) }; div() .flex() .flex_col() .rounded(theme.radius(Radius::Md)) .border_1() .border_color(colors.border) .overflow_hidden() .child( div().flex().bg(colors.hover).children( head.iter() .enumerate() .map(|(column, inline)| cell(inline, column, true, cx)), ), ) .children(rows.iter().map(|row| { div() .flex() .border_t_1() .border_color(colors.border) .children( row.iter() .enumerate() .map(|(column, inline)| cell(inline, column, false, cx)), ) })) .into_any_element() } Node::Rule => div() .my_2() .h_0() .border_t_1() .border_color(colors.border) .into_any_element(), Node::Math(tex) => div() .flex() .justify_center() .py_2() .child(match Latex::parse(tex) { Ok(math) => math.into_any_element(), Err(error) => div() .text_color(colors.danger) .child(error.to_string()) .into_any_element(), }) .into_any_element(), Node::Image { alt, url } => div() .flex() .items_center() .gap_2() .p_3() .rounded(theme.radius(Radius::Md)) .border_1() .border_color(colors.border) .text_color(colors.fg_muted) .child( Icon::new(IconName::Image) .size(IconSize::Sm) .color(colors.fg_subtle), ) .child(div().flex_1().child(alt.clone())) .child(div().text_color(colors.fg_subtle).child(url.clone())) .into_any_element(), Node::Footnote { label, body } => div() .flex() .gap_2() .text_size(theme.text_size(TextSize::Sm)) .text_color(colors.fg_muted) .child(div().flex_none().child(format!("[{label}]"))) .child( div() .flex_1() .flex() .flex_col() .children(self.nodes(body, cx)), ) .into_any_element(), } } } impl RenderOnce for MarkdownRenderer { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let nodes = parse(&self.source); let draw = Draw { id: &self.id, on_link: &self.on_link, next: std::cell::Cell::new(0), }; div() .flex() .flex_col() .gap_3() .text_size(theme.text_size(TextSize::Base)) .line_height(theme.text_size(TextSize::Base) * 1.6) .children(draw.nodes(&nodes, cx)) } }