Created
June 15, 2026 12:41
-
-
Save Zomatree/a11b86224097942b7b3f2651bf555420 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| use std::{borrow::Cow, mem}; | |
| use chumsky::{Parser as _, prelude::*}; | |
| use freya::{icons::lucide::hash, prelude::*}; | |
| use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; | |
| define_theme! { | |
| %[component] | |
| pub MarkdownViewer { | |
| %[fields] | |
| color: Color, | |
| color_link: Color, | |
| background_code: Color, | |
| color_code: Color, | |
| background_blockquote: Color, | |
| border_blockquote: Color, | |
| background_divider: Color, | |
| color_primary: Color, | |
| background_primary: Color, | |
| color_secondary: Color, | |
| background_secondary: Color, | |
| font_size: f32, | |
| } | |
| } | |
| #[derive(PartialEq)] | |
| pub struct MarkdownViewer { | |
| content: Cow<'static, str>, | |
| layout: LayoutData, | |
| key: DiffKey, | |
| pub(crate) theme: Option<MarkdownViewerThemePartial>, | |
| } | |
| impl MarkdownViewer { | |
| pub fn new(content: impl Into<Cow<'static, str>>) -> Self { | |
| Self { | |
| content: content.into(), | |
| layout: LayoutData::default(), | |
| key: DiffKey::None, | |
| theme: None, | |
| } | |
| } | |
| } | |
| impl KeyExt for MarkdownViewer { | |
| fn write_key(&mut self) -> &mut DiffKey { | |
| &mut self.key | |
| } | |
| } | |
| impl LayoutExt for MarkdownViewer { | |
| fn get_layout(&mut self) -> &mut LayoutData { | |
| &mut self.layout | |
| } | |
| } | |
| impl ContainerExt for MarkdownViewer {} | |
| /// Represents different markdown elements for rendering. | |
| #[allow(dead_code)] | |
| #[derive(Clone)] | |
| enum MarkdownElement { | |
| Heading { | |
| level: HeadingLevel, | |
| content: Vec<Inline>, | |
| }, | |
| Paragraph { | |
| content: Vec<Inline>, | |
| }, | |
| CodeBlock { | |
| code: String, | |
| #[allow(dead_code)] | |
| language: Option<String>, | |
| }, | |
| UnorderedList { | |
| items: Vec<Vec<Inline>>, | |
| }, | |
| OrderedList { | |
| start: u64, | |
| items: Vec<Vec<Inline>>, | |
| }, | |
| Image { | |
| url: String, | |
| alt: String, | |
| }, | |
| Link { | |
| url: String, | |
| title: Option<String>, | |
| text: Vec<Inline>, | |
| }, | |
| Blockquote { | |
| content: Vec<Inline>, | |
| }, | |
| Table { | |
| headers: Vec<Vec<Inline>>, | |
| rows: Vec<Vec<Vec<Inline>>>, | |
| }, | |
| HorizontalRule, | |
| } | |
| /// A piece of a paragraph's content: styled text or an inline link flowing within the text. | |
| #[derive(Clone, Debug)] | |
| enum Inline { | |
| Span(TextSpan), | |
| UserMention { | |
| avatar: String, | |
| name: String, | |
| fill: Option<Fill>, | |
| }, | |
| ChannelMention { | |
| name: String, | |
| }, | |
| RoleMention { | |
| fill: Option<Fill>, | |
| name: String, | |
| }, | |
| Emoji { | |
| id: String, | |
| }, | |
| Link { | |
| url: String, | |
| title: Option<String>, | |
| text: Vec<Inline>, | |
| }, | |
| } | |
| /// Represents styled text spans within markdown. | |
| #[derive(Clone, Debug)] | |
| struct TextSpan { | |
| text: String, | |
| bold: bool, | |
| italic: bool, | |
| #[allow(dead_code)] | |
| strikethrough: bool, | |
| code: bool, | |
| } | |
| impl TextSpan { | |
| fn new(text: impl Into<String>) -> Self { | |
| Self { | |
| text: text.into(), | |
| bold: false, | |
| italic: false, | |
| strikethrough: false, | |
| code: false, | |
| } | |
| } | |
| } | |
| /// Parse markdown content into a list of elements. | |
| fn parse_markdown(content: &str) -> Vec<MarkdownElement> { | |
| let mut options = Options::empty(); | |
| options.insert(Options::ENABLE_STRIKETHROUGH); | |
| options.insert(Options::ENABLE_TABLES); | |
| let parser = Parser::new_ext(content, options); | |
| let mut elements = Vec::new(); | |
| let mut current_content: Vec<Inline> = Vec::new(); | |
| let mut list_items: Vec<Vec<Inline>> = Vec::new(); | |
| let mut current_list_item: Vec<Inline> = Vec::new(); | |
| let mut in_heading: Option<HeadingLevel> = None; | |
| let mut in_paragraph = false; | |
| let mut in_code_block = false; | |
| let mut code_block_content = String::new(); | |
| let mut code_block_language: Option<String> = None; | |
| let mut ordered_list_start: Option<u64> = None; | |
| let mut in_list_item = false; | |
| let mut in_blockquote = false; | |
| let mut blockquote_spans: Vec<Inline> = Vec::new(); | |
| let mut in_table_cell = false; | |
| let mut table_headers: Vec<Vec<Inline>> = Vec::new(); | |
| let mut table_rows: Vec<Vec<Vec<Inline>>> = Vec::new(); | |
| let mut current_table_row: Vec<Vec<Inline>> = Vec::new(); | |
| let mut current_cell_spans: Vec<Inline> = Vec::new(); | |
| let mut in_link = false; | |
| let mut link_url: Option<String> = None; | |
| let mut link_title: Option<String> = None; | |
| let mut link_spans: Vec<Inline> = Vec::new(); | |
| let mut bold = false; | |
| let mut italic = false; | |
| let mut strikethrough = false; | |
| for event in parser { | |
| match event { | |
| Event::Start(tag) => match tag { | |
| Tag::Heading { level, .. } => { | |
| in_heading = Some(level); | |
| current_content.clear(); | |
| } | |
| Tag::Paragraph => { | |
| if in_blockquote { | |
| // Paragraphs inside blockquotes | |
| } else if in_list_item { | |
| // Paragraphs inside list items | |
| } else { | |
| in_paragraph = true; | |
| current_content.clear(); | |
| } | |
| } | |
| Tag::CodeBlock(kind) => { | |
| in_code_block = true; | |
| code_block_content.clear(); | |
| code_block_language = match kind { | |
| pulldown_cmark::CodeBlockKind::Fenced(lang) => { | |
| let lang_str = lang.to_string(); | |
| if lang_str.is_empty() { | |
| None | |
| } else { | |
| Some(lang_str) | |
| } | |
| } | |
| pulldown_cmark::CodeBlockKind::Indented => None, | |
| }; | |
| } | |
| Tag::List(start) => { | |
| ordered_list_start = start; | |
| list_items.clear(); | |
| } | |
| Tag::Item => { | |
| in_list_item = true; | |
| current_list_item.clear(); | |
| } | |
| Tag::Strong => bold = true, | |
| Tag::Emphasis => italic = true, | |
| Tag::Strikethrough => strikethrough = true, | |
| Tag::BlockQuote(_) => { | |
| in_blockquote = true; | |
| blockquote_spans.clear(); | |
| } | |
| Tag::Image { | |
| dest_url, title, .. | |
| } => { | |
| elements.push(MarkdownElement::Image { | |
| url: dest_url.to_string(), | |
| alt: title.to_string(), | |
| }); | |
| } | |
| Tag::Link { | |
| dest_url, title, .. | |
| } => { | |
| in_link = true; | |
| link_url = Some(dest_url.to_string()); | |
| link_title = Some(title.to_string()); | |
| link_spans.clear(); | |
| } | |
| Tag::Table(_) => { | |
| table_headers.clear(); | |
| table_rows.clear(); | |
| current_table_row.clear(); | |
| } | |
| Tag::TableHead => {} | |
| Tag::TableRow => { | |
| current_table_row.clear(); | |
| } | |
| Tag::TableCell => { | |
| in_table_cell = true; | |
| current_cell_spans.clear(); | |
| } | |
| _ => {} | |
| }, | |
| Event::End(tag_end) => match tag_end { | |
| TagEnd::Heading(_) => { | |
| if let Some(level) = in_heading.take() { | |
| elements.push(MarkdownElement::Heading { | |
| level, | |
| content: mem::take(&mut current_content), | |
| }); | |
| } | |
| } | |
| TagEnd::Paragraph => { | |
| if in_blockquote { | |
| blockquote_spans.append(&mut current_content) | |
| } else if in_list_item { | |
| current_list_item.append(&mut current_content) | |
| } else if in_paragraph { | |
| in_paragraph = false; | |
| elements.push(MarkdownElement::Paragraph { | |
| content: mem::take(&mut current_content), | |
| }); | |
| } | |
| } | |
| TagEnd::CodeBlock => { | |
| in_code_block = false; | |
| elements.push(MarkdownElement::CodeBlock { | |
| code: mem::take(&mut code_block_content), | |
| language: code_block_language.take(), | |
| }); | |
| } | |
| TagEnd::List(_) => { | |
| let items = mem::take(&mut list_items); | |
| if let Some(start) = ordered_list_start.take() { | |
| elements.push(MarkdownElement::OrderedList { start, items }); | |
| } else { | |
| elements.push(MarkdownElement::UnorderedList { items }); | |
| } | |
| } | |
| TagEnd::Item => { | |
| in_list_item = false; | |
| list_items.push(mem::take(&mut current_list_item)); | |
| } | |
| TagEnd::Strong => bold = false, | |
| TagEnd::Emphasis => italic = false, | |
| TagEnd::Strikethrough => strikethrough = false, | |
| TagEnd::BlockQuote(_) => { | |
| in_blockquote = false; | |
| elements.push(MarkdownElement::Blockquote { | |
| content: mem::take(&mut blockquote_spans), | |
| }); | |
| } | |
| TagEnd::Table => { | |
| elements.push(MarkdownElement::Table { | |
| headers: mem::take(&mut table_headers), | |
| rows: mem::take(&mut table_rows), | |
| }); | |
| } | |
| TagEnd::TableHead => { | |
| // TableHead contains cells directly (no TableRow), so save headers here | |
| table_headers = mem::take(&mut current_table_row); | |
| } | |
| TagEnd::TableRow => { | |
| // TableRow only appears in body rows, not in TableHead | |
| table_rows.push(mem::take(&mut current_table_row)); | |
| } | |
| TagEnd::TableCell => { | |
| in_table_cell = false; | |
| current_table_row.push(mem::take(&mut current_cell_spans)); | |
| } | |
| TagEnd::Link => { | |
| in_link = false; | |
| if let Some(url) = link_url.take() { | |
| let title = link_title.take(); | |
| let text = mem::take(&mut link_spans); | |
| if in_paragraph { | |
| current_content.push(Inline::Link { url, title, text }); | |
| } else { | |
| elements.push(MarkdownElement::Link { url, title, text }); | |
| } | |
| } | |
| } | |
| _ => {} | |
| }, | |
| Event::Text(text) => { | |
| if in_code_block { | |
| code_block_content.push_str(text.trim()); | |
| } else if in_table_cell { | |
| let text = if let Some(Inline::Span(last)) = current_cell_spans.last() { | |
| let output = last.text.to_string() + &*text; | |
| current_cell_spans.pop(); | |
| output | |
| } else { | |
| text.to_string() | |
| }; | |
| current_cell_spans.extend(parse_elements(&text, bold, italic, strikethrough)); | |
| } else { | |
| if in_blockquote && !in_paragraph { | |
| let text = if let Some(Inline::Span(last)) = blockquote_spans.last() { | |
| let output = last.text.to_string() + &*text; | |
| blockquote_spans.pop(); | |
| output | |
| } else { | |
| text.to_string() | |
| }; | |
| blockquote_spans.extend(parse_elements(&text, bold, italic, strikethrough)); | |
| } else if in_list_item && !in_paragraph { | |
| let text = if let Some(Inline::Span(last)) = current_list_item.last() { | |
| let output = last.text.to_string() + &*text; | |
| current_list_item.pop(); | |
| output | |
| } else { | |
| text.to_string() | |
| }; | |
| current_list_item.extend(parse_elements( | |
| &text, | |
| bold, | |
| italic, | |
| strikethrough, | |
| )); | |
| } else if in_link { | |
| let text = if let Some(Inline::Span(last)) = link_spans.last() { | |
| let output = last.text.to_string() + &*text; | |
| link_spans.pop(); | |
| output | |
| } else { | |
| text.to_string() | |
| }; | |
| link_spans.extend(parse_elements(&text, bold, italic, strikethrough)); | |
| } else { | |
| let text = if let Some(Inline::Span(last)) = current_content.last() { | |
| let output = last.text.to_string() + &*text; | |
| current_content.pop(); | |
| output | |
| } else { | |
| text.to_string() | |
| }; | |
| current_content.extend(parse_elements(&text, bold, italic, strikethrough)); | |
| } | |
| } | |
| } | |
| Event::Code(code) => { | |
| let span = Inline::Span(TextSpan { | |
| text: code.to_string(), | |
| bold, | |
| italic, | |
| strikethrough, | |
| code: true, | |
| }); | |
| if in_table_cell { | |
| current_cell_spans.push(span); | |
| } else if in_blockquote { | |
| blockquote_spans.push(span); | |
| } else if in_list_item { | |
| current_list_item.push(span); | |
| } else if in_link { | |
| link_spans.push(span); | |
| } else { | |
| current_content.push(span); | |
| } | |
| } | |
| Event::SoftBreak | Event::HardBreak => { | |
| let span = Inline::Span(TextSpan::new("\n")); | |
| if in_blockquote { | |
| blockquote_spans.push(span); | |
| } else if in_list_item { | |
| current_list_item.push(span); | |
| } else if in_link { | |
| link_spans.push(span); | |
| } else { | |
| current_content.push(span); | |
| } | |
| } | |
| Event::Rule => { | |
| elements.push(MarkdownElement::HorizontalRule); | |
| } | |
| _ => {} | |
| } | |
| } | |
| elements | |
| } | |
| #[derive(Debug, Clone)] | |
| enum InlineParserElement { | |
| Text(String), | |
| UserMention(String), | |
| ChannelMention(String), | |
| RoleMention(String), | |
| Emoji(String), | |
| Link { | |
| url: String, | |
| title: Option<String>, | |
| text: String, | |
| }, | |
| } | |
| fn id_parser<'a>() -> impl chumsky::Parser<'a, &'a str, String> { | |
| one_of("0123456789ABCDEFGHJKMNPQRSTVWXYZ") | |
| .repeated() | |
| .exactly(26) | |
| .collect() | |
| } | |
| fn user_mention_parser<'a>() -> impl chumsky::Parser<'a, &'a str, InlineParserElement> { | |
| just("@") | |
| .ignore_then(id_parser()) | |
| .delimited_by(just("<"), just(">")) | |
| .map(InlineParserElement::UserMention) | |
| } | |
| fn channel_mention_parser<'a>() -> impl chumsky::Parser<'a, &'a str, InlineParserElement> { | |
| just("#") | |
| .ignore_then(id_parser()) | |
| .delimited_by(just("<"), just(">")) | |
| .map(InlineParserElement::ChannelMention) | |
| } | |
| fn role_mention_parser<'a>() -> impl chumsky::Parser<'a, &'a str, InlineParserElement> { | |
| just("%") | |
| .ignore_then(id_parser()) | |
| .delimited_by(just("<"), just(">")) | |
| .map(InlineParserElement::RoleMention) | |
| } | |
| fn emoji_mention_parser<'a>() -> impl chumsky::Parser<'a, &'a str, InlineParserElement> { | |
| id_parser() | |
| .delimited_by(just(":"), just(":")) | |
| .map(InlineParserElement::Emoji) | |
| } | |
| fn link_parser<'a>() -> impl chumsky::Parser<'a, &'a str, InlineParserElement> { | |
| none_of(']') | |
| .repeated() | |
| .collect() | |
| .delimited_by(just("["), just("]")) | |
| .then( | |
| none_of(" )") | |
| .repeated() | |
| .collect::<String>() | |
| // .then( | |
| // just(' ').repeated().at_least(1).ignore_then( | |
| // none_of("\"") | |
| // .repeated() | |
| // .collect::<String>() | |
| // .delimited_by(just("\""), just("\"")) | |
| // .or_not(), | |
| // ), | |
| // ) | |
| .delimited_by(just('('), just(')')), | |
| ) | |
| .map(|(text, (url))| InlineParserElement::Link { | |
| url, | |
| title: None, | |
| text, | |
| }) | |
| } | |
| fn custom_element_parser<'a>() -> impl chumsky::Parser<'a, &'a str, InlineParserElement> { | |
| choice(( | |
| user_mention_parser(), | |
| channel_mention_parser(), | |
| role_mention_parser(), | |
| emoji_mention_parser(), | |
| link_parser(), | |
| )) | |
| } | |
| fn element_parser<'a>() -> impl chumsky::Parser<'a, &'a str, InlineParserElement> { | |
| custom_element_parser().or(any::<&str, _>().map(|c| InlineParserElement::Text(c.to_string()))) | |
| } | |
| fn inline_parser<'a>() -> impl chumsky::Parser<'a, &'a str, Vec<InlineParserElement>> { | |
| element_parser().repeated().collect() | |
| } | |
| #[test] | |
| fn test() { | |
| println!( | |
| "{:?}", | |
| inline_parser() | |
| .parse("Hello <@01FD58YK5W7QRV5H3D64KTQYX3> :01J7E64FE9R80ZS9RCA5HRAPJ6:. <#01F92C5ZXBQWQ8KY7J8KY917NM> asdasdasd; jklh.\nasdjklhwe sdfjklhqwer fsdljkhsdfa <%01FD6CBW8QSWHSVJAXNM0K2GET> :01GJTC4RD6XAJXRAAM30KW25VD:") | |
| .into_output() | |
| ) | |
| } | |
| fn map_element( | |
| element: InlineParserElement, | |
| bold: bool, | |
| italic: bool, | |
| strikethrough: bool, | |
| ) -> Inline { | |
| match element { | |
| InlineParserElement::Text(text) => Inline::Span(TextSpan { | |
| text, | |
| bold, | |
| italic, | |
| strikethrough, | |
| code: false, | |
| }), | |
| InlineParserElement::UserMention(id) => Inline::UserMention { | |
| avatar: "DZdeerdwrU6rvfDOOGdNUptzHZT8Ri0cHCo0Z1sz99".to_string(), | |
| name: "Zomatree".to_string(), | |
| fill: None, | |
| }, | |
| InlineParserElement::ChannelMention(id) => Inline::ChannelMention { | |
| name: "General".to_string(), | |
| }, | |
| InlineParserElement::RoleMention(id) => Inline::RoleMention { | |
| fill: Some(Fill::Color(Color::RED)), | |
| name: "Admin".to_string(), | |
| }, | |
| InlineParserElement::Emoji(id) => Inline::Emoji { id }, | |
| InlineParserElement::Link { url, title, text } => Inline::Link { | |
| url, | |
| title, | |
| text: parse_elements(&text, bold, italic, strikethrough), | |
| }, | |
| } | |
| } | |
| fn parse_elements(content: &str, bold: bool, italic: bool, strikethrough: bool) -> Vec<Inline> { | |
| inline_parser() | |
| .parse(content) | |
| .into_output() | |
| .unwrap_or_default() | |
| .into_iter() | |
| .map(|element| map_element(element, bold, italic, strikethrough)) | |
| .collect() | |
| } | |
| /// Build a styled [Span] from a markdown text span. | |
| fn styled_span(span: &TextSpan, text_color: Color, code_color: Color) -> Span<'static> { | |
| let mut styled = Span::new(span.text.clone()); | |
| if span.bold { | |
| styled = styled.font_weight(FontWeight::BOLD); | |
| } | |
| if span.italic { | |
| styled = styled.font_slant(FontSlant::Italic); | |
| } | |
| if span.code { | |
| styled.font_family("monospace").color(code_color) | |
| } else { | |
| styled.color(text_color) | |
| } | |
| } | |
| fn render_user_mention( | |
| avatar: &str, | |
| name: &str, | |
| fill: Option<Fill>, | |
| base_font_size: f32, | |
| text_color: Color, | |
| background_color: Color, | |
| ) -> impl IntoElement { | |
| let size = base_font_size * (16. / 14.); | |
| let mut label = label(); | |
| label.get_text_style_data().color = Some(fill.unwrap_or(Fill::Color(text_color))); | |
| rect() | |
| .padding((0., 6., 0., 2.)) | |
| .horizontal() | |
| .cross_align(Alignment::Center) | |
| .spacing(4.) | |
| .corner_radius(16.) | |
| .background(background_color) | |
| .color(text_color) | |
| .font_weight(FontWeight::SEMI_BOLD) | |
| .child( | |
| ImageViewer::new( | |
| format!("https://cdn.stoatusercontent.com/avatars/{avatar}") | |
| .parse::<Uri>() | |
| .unwrap(), | |
| ) | |
| .sampling_mode(SamplingMode::Trilinear) | |
| .aspect_ratio(AspectRatio::Max) | |
| .image_cover(ImageCover::Center) | |
| .corner_radius(size / 2.) | |
| .width(Size::px(size)) | |
| .height(Size::px(size)), | |
| ) | |
| .child( | |
| label | |
| .line_height(1.5) | |
| .max_lines(1) | |
| .font_size(base_font_size) | |
| .text(name.to_string()), | |
| ) | |
| } | |
| fn render_channel_mention( | |
| name: &str, | |
| base_font_size: f32, | |
| text_color: Color, | |
| background_color: Color, | |
| ) -> impl IntoElement { | |
| let size = base_font_size * (16. / 14.); | |
| rect() | |
| .padding((0., 6., 0., 2.)) | |
| .horizontal() | |
| .cross_align(Alignment::Center) | |
| .spacing(4.) | |
| .corner_radius(16.) | |
| .background(background_color) | |
| .color(text_color) | |
| .font_weight(FontWeight::SEMI_BOLD) | |
| .child(svg(hash()).width(Size::px(size)).height(Size::px(size))) | |
| .child( | |
| label() | |
| .line_height(1.5) | |
| .max_lines(1) | |
| .font_size(base_font_size) | |
| .text(name.to_string()), | |
| ) | |
| } | |
| fn render_role_mention( | |
| name: &str, | |
| fill: &Option<Fill>, | |
| base_font_size: f32, | |
| text_color: Color, | |
| background_color: Color, | |
| ) -> impl IntoElement { | |
| let size = base_font_size * (16. / 14.); | |
| let fill = fill.clone().unwrap_or(Fill::Color(text_color.clone())); | |
| let mut container = rect(); | |
| container.get_text_style_data().color = Some(fill.clone()); | |
| let mut circle = rect(); | |
| circle.get_style().background = fill; | |
| container | |
| .padding((0., 6., 0., 2.)) | |
| .horizontal() | |
| .cross_align(Alignment::Center) | |
| .spacing(4.) | |
| .corner_radius(16.) | |
| .background(background_color) | |
| .font_weight(FontWeight::SEMI_BOLD) | |
| .child( | |
| circle | |
| .corner_radius(size / 2.) | |
| .width(Size::px(size)) | |
| .height(Size::px(size)), | |
| ) | |
| .child( | |
| label() | |
| .line_height(1.5) | |
| .max_lines(1) | |
| .font_size(base_font_size) | |
| .text(name.to_string()), | |
| ) | |
| } | |
| fn render_emoji(id: &str, base_font_size: f32) -> impl IntoElement { | |
| let size = base_font_size * 1.4; | |
| rect().padding((0., 0.7, 0., 1.4)).child( | |
| ImageViewer::new( | |
| format!("https://cdn.stoatusercontent.com/emojis/{id}") | |
| .parse::<Uri>() | |
| .unwrap(), | |
| ) | |
| .sampling_mode(SamplingMode::Trilinear) | |
| .aspect_ratio(AspectRatio::Max) | |
| .image_cover(ImageCover::Center) | |
| .width(Size::px(size)) | |
| .height(Size::px(size)), | |
| ) | |
| } | |
| fn render_link( | |
| content: &[Inline], | |
| url: &str, | |
| title: &Option<String>, | |
| base_font_size: f32, | |
| text_color: Color, | |
| ) -> impl IntoElement + KeyExt { | |
| rect() | |
| } | |
| fn render_content( | |
| content: &[Inline], | |
| base_font_size: f32, | |
| text_color: Color, | |
| link_color: Color, | |
| code_color: Color, | |
| primary_foreground: Color, | |
| primary_background: Color, | |
| secondary_foreground: Color, | |
| secondary_background: Color, | |
| ) -> Paragraph { | |
| let mut result = paragraph().font_size(base_font_size); | |
| for item in content { | |
| result = match item { | |
| Inline::Span(span) => result.span(styled_span(span, text_color, code_color)), | |
| Inline::Link { text, url, title } => { | |
| result.child(render_link(text, url, title, base_font_size, link_color)) | |
| } | |
| Inline::UserMention { avatar, name, fill } => result.child(render_user_mention( | |
| avatar, | |
| name, | |
| fill.clone(), | |
| base_font_size, | |
| primary_foreground, | |
| primary_background, | |
| )), | |
| Inline::ChannelMention { name } => result.child(render_channel_mention( | |
| name, | |
| base_font_size, | |
| secondary_foreground, | |
| secondary_background, | |
| )), | |
| Inline::RoleMention { fill, name } => result.child(render_role_mention( | |
| name, | |
| fill, | |
| base_font_size, | |
| primary_foreground, | |
| primary_background, | |
| )), | |
| Inline::Emoji { id } => result.child(render_emoji(id, base_font_size)), | |
| }; | |
| } | |
| result | |
| } | |
| impl Component for MarkdownViewer { | |
| fn render(&self) -> impl IntoElement { | |
| let elements = parse_markdown(&self.content); | |
| let MarkdownViewerTheme { | |
| color, | |
| color_link, | |
| background_code, | |
| color_code, | |
| background_blockquote, | |
| border_blockquote, | |
| background_divider, | |
| font_size, | |
| color_primary, | |
| background_primary, | |
| color_secondary, | |
| background_secondary, | |
| } = crate::get_theme!( | |
| &self.theme, | |
| MarkdownViewerThemePreference, | |
| "custom_markdown_viewer" | |
| ); | |
| let mut container = rect().vertical().layout(self.layout.clone()).spacing(12.); | |
| for (idx, element) in elements.into_iter().enumerate() { | |
| let child: Element = match element { | |
| MarkdownElement::Heading { level, content } => { | |
| let multiplier = match level { | |
| HeadingLevel::H1 => 2., | |
| HeadingLevel::H2 => 1.6, | |
| HeadingLevel::H3 => 1.4, | |
| HeadingLevel::H4 => 1.2, | |
| HeadingLevel::H5 => 1., | |
| HeadingLevel::H6 => 0.8, | |
| }; | |
| render_content( | |
| &content, | |
| multiplier * font_size, | |
| color, | |
| color_link, | |
| color_code, | |
| color_primary, | |
| background_primary, | |
| color_secondary, | |
| background_secondary, | |
| ) | |
| .font_weight(FontWeight::BOLD) | |
| .key(idx) | |
| .into() | |
| } | |
| MarkdownElement::Paragraph { content } => render_content( | |
| &content, | |
| font_size, | |
| color, | |
| color_link, | |
| color_code, | |
| color_primary, | |
| background_primary, | |
| color_secondary, | |
| background_secondary, | |
| ) | |
| .key(idx) | |
| .into(), | |
| MarkdownElement::CodeBlock { code, .. } => rect() | |
| .key(idx) | |
| .width(Size::fill()) | |
| .background(background_code) | |
| .corner_radius(6.) | |
| .padding(Gaps::new_all(12.)) | |
| .child( | |
| label() | |
| .text(code) | |
| .font_family("monospace") | |
| .font_size(font_size) | |
| .color(color_code), | |
| ) | |
| .into(), | |
| MarkdownElement::UnorderedList { items } => { | |
| let mut list = rect() | |
| .key(idx) | |
| .vertical() | |
| .spacing(4.) | |
| .padding(Gaps::new(0., 0., 0., 20.)); | |
| for (item_idx, item_spans) in items.into_iter().enumerate() { | |
| let item_content = rect() | |
| .key(item_idx) | |
| .horizontal() | |
| .cross_align(Alignment::Start) | |
| .spacing(8.) | |
| .child(label().text("•").font_size(font_size).color(color)) | |
| .child(render_content( | |
| &item_spans, | |
| font_size, | |
| color, | |
| color_link, | |
| color_code, | |
| color_primary, | |
| background_primary, | |
| color_secondary, | |
| background_secondary, | |
| )); | |
| list = list.child(item_content); | |
| } | |
| list.into() | |
| } | |
| MarkdownElement::OrderedList { start, items } => { | |
| let mut list = rect() | |
| .key(idx) | |
| .vertical() | |
| .spacing(4.) | |
| .padding(Gaps::new(0., 0., 0., 20.)); | |
| for (item_idx, item_spans) in items.into_iter().enumerate() { | |
| let number = start + item_idx as u64; | |
| let item_content = rect() | |
| .key(item_idx) | |
| .horizontal() | |
| .cross_align(Alignment::Start) | |
| .spacing(8.) | |
| .child( | |
| label() | |
| .text(format!("{}.", number)) | |
| .font_size(font_size) | |
| .color(color), | |
| ) | |
| .child(render_content( | |
| &item_spans, | |
| font_size, | |
| color, | |
| color_link, | |
| color_code, | |
| color_primary, | |
| background_primary, | |
| color_secondary, | |
| background_secondary, | |
| )); | |
| list = list.child(item_content); | |
| } | |
| list.into() | |
| } | |
| MarkdownElement::Image { alt, .. } => label() | |
| .key(idx) | |
| .text(format!("[Image: {}]", alt)) | |
| .color(color) | |
| .into(), | |
| MarkdownElement::Link { text, url, title } => { | |
| render_link(&text, &url, &title, font_size, color_link) | |
| .key(idx) | |
| .into_element() | |
| } | |
| MarkdownElement::Blockquote { content: spans } => rect() | |
| .key(idx) | |
| .width(Size::fill()) | |
| .padding((4., 12.)) | |
| .corner_radius(8.) | |
| .border( | |
| Border::new() | |
| .width(BorderWidth { | |
| top: 0., | |
| right: 0., | |
| left: 4., | |
| bottom: 0., | |
| }) | |
| .fill(border_blockquote) | |
| .alignment(BorderAlignment::Inner), | |
| ) | |
| .background(background_blockquote) | |
| .child( | |
| render_content( | |
| &spans, | |
| font_size, | |
| color, | |
| color_link, | |
| color_code, | |
| color_primary, | |
| background_primary, | |
| color_secondary, | |
| background_secondary, | |
| ) | |
| .font_slant(FontSlant::Italic), | |
| ) | |
| .into(), | |
| MarkdownElement::HorizontalRule => rect() | |
| .key(idx) | |
| .width(Size::fill()) | |
| .height(Size::px(1.)) | |
| .background(background_divider) | |
| .into(), | |
| MarkdownElement::Table { headers, rows } => { | |
| let mut head = TableHead::new(); | |
| let mut header_row = TableRow::new(); | |
| let column_count = headers.len(); | |
| for (col_idx, header_spans) in headers.into_iter().enumerate() { | |
| header_row = header_row.child( | |
| TableCell::new().key(col_idx).child( | |
| render_content( | |
| &header_spans, | |
| font_size, | |
| color, | |
| color_link, | |
| color_code, | |
| color_primary, | |
| background_primary, | |
| color_secondary, | |
| background_secondary, | |
| ) | |
| .font_weight(FontWeight::BOLD) | |
| .width(Size::Fill) | |
| .text_align(TextAlign::Start), | |
| ), | |
| ); | |
| } | |
| head = head.child(header_row); | |
| let mut body = TableBody::new(); | |
| for (row_idx, row) in rows.into_iter().enumerate() { | |
| let mut table_row = TableRow::new().key(row_idx); | |
| for (col_idx, cell_spans) in row.into_iter().enumerate() { | |
| table_row = table_row.child( | |
| TableCell::new().key(col_idx).child( | |
| render_content( | |
| &cell_spans, | |
| font_size, | |
| color, | |
| color_link, | |
| color_code, | |
| color_primary, | |
| background_primary, | |
| color_secondary, | |
| background_secondary, | |
| ) | |
| .width(Size::Fill) | |
| .text_align(TextAlign::Start), | |
| ), | |
| ); | |
| } | |
| body = body.child(table_row); | |
| } | |
| Table::new() | |
| .column_widths(vec![Size::flex(1.); column_count]) | |
| .key(idx) | |
| .child(head) | |
| .child(body) | |
| .into() | |
| } | |
| }; | |
| container = container.child(child); | |
| } | |
| container | |
| } | |
| fn render_key(&self) -> DiffKey { | |
| self.key.clone().or(self.default_key()) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment