//! Markdown widgets can parse and display Markdown. //! //! You can enable the `highlighter` feature for syntax highlighting //! in code blocks. //! //! Only the variants of [`Item`] are currently supported. //! //! # Example //! ```no_run //! # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; } //! # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; //! # //! use iced::widget::markdown; //! use iced::Theme; //! //! struct State { //! markdown: Vec, //! } //! //! enum Message { //! LinkClicked(markdown::Url), //! } //! //! impl State { //! pub fn new() -> Self { //! Self { //! markdown: markdown::parse("This is some **Markdown**!").collect(), //! } //! } //! //! fn view(&self) -> Element<'_, Message> { //! markdown::view(&self.markdown, Theme::TokyoNight) //! .map(Message::LinkClicked) //! .into() //! } //! //! fn update(state: &mut State, message: Message) { //! match message { //! Message::LinkClicked(url) => { //! println!("The following url was clicked: {url}"); //! } //! } //! } //! } //! ``` use crate::core::border; use crate::core::font::{self, Font}; use crate::core::padding; use crate::core::theme; use crate::core::{ self, Color, Element, Length, Padding, Pixels, Theme, color, }; use crate::{column, container, rich_text, row, scrollable, span, text}; use std::borrow::BorrowMut; use std::cell::{Cell, RefCell}; use std::collections::{HashMap, HashSet}; use std::mem; use std::ops::Range; use std::rc::Rc; use std::sync::Arc; pub use core::text::Highlight; pub use pulldown_cmark::HeadingLevel; pub use url::Url; /// A bunch of Markdown that has been parsed. #[derive(Debug, Default)] pub struct Content { items: Vec, incomplete: HashMap, state: State, } #[derive(Debug)] struct Section { content: String, broken_links: HashSet, } impl Content { /// Creates a new empty [`Content`]. pub fn new() -> Self { Self::default() } /// Creates some new [`Content`] by parsing the given Markdown. pub fn parse(markdown: &str) -> Self { let mut content = Self::new(); content.push_str(markdown); content } /// Pushes more Markdown into the [`Content`]; parsing incrementally! /// /// This is specially useful when you have long streams of Markdown; like /// big files or potentially long replies. pub fn push_str(&mut self, markdown: &str) { if markdown.is_empty() { return; } // Append to last leftover text let mut leftover = std::mem::take(&mut self.state.leftover); leftover.push_str(markdown); // Pop the last item let _ = self.items.pop(); // Re-parse last item and new text for (item, source, broken_links) in parse_with(&mut self.state, &leftover) { if !broken_links.is_empty() { let _ = self.incomplete.insert( self.items.len(), Section { content: source.to_owned(), broken_links, }, ); } self.items.push(item); } // Re-parse incomplete sections if new references are available if !self.incomplete.is_empty() { self.incomplete.retain(|index, section| { if self.items.len() <= *index { return false; } let broken_links_before = section.broken_links.len(); section .broken_links .retain(|link| !self.state.references.contains_key(link)); if broken_links_before != section.broken_links.len() { let mut state = State { leftover: String::new(), references: self.state.references.clone(), images: HashSet::new(), #[cfg(feature = "highlighter")] highlighter: None, }; if let Some((item, _source, _broken_links)) = parse_with(&mut state, §ion.content).next() { self.items[*index] = item; } self.state.images.extend(state.images.drain()); drop(state); } !section.broken_links.is_empty() }); } } /// Returns the Markdown items, ready to be rendered. /// /// You can use [`view`] to turn them into an [`Element`]. pub fn items(&self) -> &[Item] { &self.items } /// Returns the URLs of the Markdown images present in the [`Content`]. pub fn images(&self) -> &HashSet { &self.state.images } } /// A Markdown item. #[derive(Debug, Clone)] pub enum Item { /// A heading. Heading(pulldown_cmark::HeadingLevel, Text), /// A paragraph. Paragraph(Text), /// A code block. /// /// You can enable the `highlighter` feature for syntax highlighting. CodeBlock { /// The language of the code block, if any. language: Option, /// The raw code of the code block. code: String, /// The styled lines of text in the code block. lines: Vec, }, /// A list. List { /// The first number of the list, if it is ordered. start: Option, /// The items of the list. items: Vec>, }, /// An image. Image { /// The destination URL of the image. url: Url, /// The title of the image. title: String, /// The alternative text of the image. alt: Text, }, } /// A bunch of parsed Markdown text. #[derive(Debug, Clone)] pub struct Text { spans: Vec, last_style: Cell>, last_styled_spans: RefCell]>>, } impl Text { fn new(spans: Vec) -> Self { Self { spans, last_style: Cell::default(), last_styled_spans: RefCell::default(), } } /// Returns the [`rich_text()`] spans ready to be used for the given style. /// /// This method performs caching for you. It will only reallocate if the [`Style`] /// provided changes. pub fn spans(&self, style: Style) -> Arc<[text::Span<'static, Url>]> { if Some(style) != self.last_style.get() { *self.last_styled_spans.borrow_mut() = self.spans.iter().map(|span| span.view(&style)).collect(); self.last_style.set(Some(style)); } self.last_styled_spans.borrow().clone() } } #[derive(Debug, Clone)] enum Span { Standard { text: String, strikethrough: bool, link: Option, strong: bool, emphasis: bool, code: bool, }, #[cfg(feature = "highlighter")] Highlight { text: String, color: Option, font: Option, }, } impl Span { fn view(&self, style: &Style) -> text::Span<'static, Url> { match self { Span::Standard { text, strikethrough, link, strong, emphasis, code, } => { let span = span(text.clone()).strikethrough(*strikethrough); let span = if *code { span.font(Font::MONOSPACE) .color(style.inline_code_color) .background(style.inline_code_highlight.background) .border(style.inline_code_highlight.border) .padding(style.inline_code_padding) } else if *strong || *emphasis { span.font(Font { weight: if *strong { font::Weight::Bold } else { font::Weight::Normal }, style: if *emphasis { font::Style::Italic } else { font::Style::Normal }, ..Font::default() }) } else { span }; let span = if let Some(link) = link.as_ref() { span.color(style.link_color).link(link.clone()) } else { span }; span } #[cfg(feature = "highlighter")] Span::Highlight { text, color, font } => { span(text.clone()).color_maybe(*color).font_maybe(*font) } } } } /// Parse the given Markdown content. /// /// # Example /// ```no_run /// # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; } /// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; /// # /// use iced::widget::markdown; /// use iced::Theme; /// /// struct State { /// markdown: Vec, /// } /// /// enum Message { /// LinkClicked(markdown::Url), /// } /// /// impl State { /// pub fn new() -> Self { /// Self { /// markdown: markdown::parse("This is some **Markdown**!").collect(), /// } /// } /// /// fn view(&self) -> Element<'_, Message> { /// markdown::view(&self.markdown, Theme::TokyoNight) /// .map(Message::LinkClicked) /// .into() /// } /// /// fn update(state: &mut State, message: Message) { /// match message { /// Message::LinkClicked(url) => { /// println!("The following url was clicked: {url}"); /// } /// } /// } /// } /// ``` pub fn parse(markdown: &str) -> impl Iterator + '_ { parse_with(State::default(), markdown) .map(|(item, _source, _broken_links)| item) } #[derive(Debug, Default)] struct State { leftover: String, references: HashMap, images: HashSet, #[cfg(feature = "highlighter")] highlighter: Option, } #[cfg(feature = "highlighter")] #[derive(Debug)] struct Highlighter { lines: Vec<(String, Vec)>, language: String, parser: iced_highlighter::Stream, current: usize, } #[cfg(feature = "highlighter")] impl Highlighter { pub fn new(language: &str) -> Self { Self { lines: Vec::new(), parser: iced_highlighter::Stream::new( &iced_highlighter::Settings { theme: iced_highlighter::Theme::Base16Ocean, token: language.to_owned(), }, ), language: language.to_owned(), current: 0, } } pub fn prepare(&mut self) { self.current = 0; } pub fn highlight_line(&mut self, text: &str) -> &[Span] { match self.lines.get(self.current) { Some(line) if line.0 == text => {} _ => { if self.current + 1 < self.lines.len() { log::debug!("Resetting highlighter..."); self.parser.reset(); self.lines.truncate(self.current); for line in &self.lines { log::debug!( "Refeeding {n} lines", n = self.lines.len() ); let _ = self.parser.highlight_line(&line.0); } } log::trace!("Parsing: {text}", text = text.trim_end()); if self.current + 1 < self.lines.len() { self.parser.commit(); } let mut spans = Vec::new(); for (range, highlight) in self.parser.highlight_line(text) { spans.push(Span::Highlight { text: text[range].to_owned(), color: highlight.color(), font: highlight.font(), }); } if self.current + 1 == self.lines.len() { let _ = self.lines.pop(); } self.lines.push((text.to_owned(), spans)); } } self.current += 1; &self .lines .get(self.current - 1) .expect("Line must be parsed") .1 } } fn parse_with<'a>( mut state: impl BorrowMut + 'a, markdown: &'a str, ) -> impl Iterator)> + 'a { enum Scope { List(List), } struct List { start: Option, items: Vec>, } let broken_links = Rc::new(RefCell::new(HashSet::new())); let mut spans = Vec::new(); let mut code = String::new(); let mut code_language = None; let mut code_lines = Vec::new(); let mut strong = false; let mut emphasis = false; let mut strikethrough = false; let mut metadata = false; let mut table = false; let mut link = None; let mut image = None; let mut stack = Vec::new(); #[cfg(feature = "highlighter")] let mut highlighter = None; let parser = pulldown_cmark::Parser::new_with_broken_link_callback( markdown, pulldown_cmark::Options::ENABLE_YAML_STYLE_METADATA_BLOCKS | pulldown_cmark::Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS | pulldown_cmark::Options::ENABLE_TABLES | pulldown_cmark::Options::ENABLE_STRIKETHROUGH, { let references = state.borrow().references.clone(); let broken_links = broken_links.clone(); Some(move |broken_link: pulldown_cmark::BrokenLink<'_>| { if let Some(reference) = references.get(broken_link.reference.as_ref()) { Some(( pulldown_cmark::CowStr::from(reference.to_owned()), broken_link.reference.into_static(), )) } else { let _ = RefCell::borrow_mut(&broken_links) .insert(broken_link.reference.into_string()); None } }) }, ); let references = &mut state.borrow_mut().references; for reference in parser.reference_definitions().iter() { let _ = references .insert(reference.0.to_owned(), reference.1.dest.to_string()); } let produce = move |state: &mut State, stack: &mut Vec, item, source: Range| { if let Some(scope) = stack.last_mut() { match scope { Scope::List(list) => { list.items.last_mut().expect("item context").push(item); } } None } else { state.leftover = markdown[source.start..].to_owned(); Some(( item, &markdown[source.start..source.end], broken_links.take(), )) } }; let parser = parser.into_offset_iter(); // We want to keep the `spans` capacity #[allow(clippy::drain_collect)] parser.filter_map(move |(event, source)| match event { pulldown_cmark::Event::Start(tag) => match tag { pulldown_cmark::Tag::Strong if !metadata && !table => { strong = true; None } pulldown_cmark::Tag::Emphasis if !metadata && !table => { emphasis = true; None } pulldown_cmark::Tag::Strikethrough if !metadata && !table => { strikethrough = true; None } pulldown_cmark::Tag::Link { dest_url, .. } if !metadata && !table => { match Url::parse(&dest_url) { Ok(url) if url.scheme() == "http" || url.scheme() == "https" => { link = Some(url); } _ => {} } None } pulldown_cmark::Tag::Image { dest_url, title, .. } if !metadata && !table => { image = Url::parse(&dest_url) .ok() .map(|url| (url, title.into_string())); None } pulldown_cmark::Tag::List(first_item) if !metadata && !table => { let prev = if spans.is_empty() { None } else { produce( state.borrow_mut(), &mut stack, Item::Paragraph(Text::new(spans.drain(..).collect())), source, ) }; stack.push(Scope::List(List { start: first_item, items: Vec::new(), })); prev } pulldown_cmark::Tag::Item => { if let Some(Scope::List(list)) = stack.last_mut() { list.items.push(Vec::new()); } None } pulldown_cmark::Tag::CodeBlock( pulldown_cmark::CodeBlockKind::Fenced(language), ) if !metadata && !table => { #[cfg(feature = "highlighter")] { highlighter = Some({ let mut highlighter = state .borrow_mut() .highlighter .take() .filter(|highlighter| { highlighter.language == language.as_ref() }) .unwrap_or_else(|| Highlighter::new(&language)); highlighter.prepare(); highlighter }); } code_language = (!language.is_empty()).then(|| language.into_string()); let prev = if spans.is_empty() { None } else { produce( state.borrow_mut(), &mut stack, Item::Paragraph(Text::new(spans.drain(..).collect())), source, ) }; prev } pulldown_cmark::Tag::MetadataBlock(_) => { metadata = true; None } pulldown_cmark::Tag::Table(_) => { table = true; None } _ => None, }, pulldown_cmark::Event::End(tag) => match tag { pulldown_cmark::TagEnd::Heading(level) if !metadata && !table => { produce( state.borrow_mut(), &mut stack, Item::Heading(level, Text::new(spans.drain(..).collect())), source, ) } pulldown_cmark::TagEnd::Strong if !metadata && !table => { strong = false; None } pulldown_cmark::TagEnd::Emphasis if !metadata && !table => { emphasis = false; None } pulldown_cmark::TagEnd::Strikethrough if !metadata && !table => { strikethrough = false; None } pulldown_cmark::TagEnd::Link if !metadata && !table => { link = None; None } pulldown_cmark::TagEnd::Paragraph if !metadata && !table => { if spans.is_empty() { None } else { produce( state.borrow_mut(), &mut stack, Item::Paragraph(Text::new(spans.drain(..).collect())), source, ) } } pulldown_cmark::TagEnd::Item if !metadata && !table => { if spans.is_empty() { None } else { produce( state.borrow_mut(), &mut stack, Item::Paragraph(Text::new(spans.drain(..).collect())), source, ) } } pulldown_cmark::TagEnd::List(_) if !metadata && !table => { let scope = stack.pop()?; let Scope::List(list) = scope; produce( state.borrow_mut(), &mut stack, Item::List { start: list.start, items: list.items, }, source, ) } pulldown_cmark::TagEnd::Image if !metadata && !table => { let (url, title) = image.take()?; let alt = Text::new(spans.drain(..).collect()); let state = state.borrow_mut(); let _ = state.images.insert(url.clone()); produce( state, &mut stack, Item::Image { url, title, alt }, source, ) } pulldown_cmark::TagEnd::CodeBlock if !metadata && !table => { #[cfg(feature = "highlighter")] { state.borrow_mut().highlighter = highlighter.take(); } produce( state.borrow_mut(), &mut stack, Item::CodeBlock { language: code_language.take(), code: mem::take(&mut code), lines: code_lines.drain(..).collect(), }, source, ) } pulldown_cmark::TagEnd::MetadataBlock(_) => { metadata = false; None } pulldown_cmark::TagEnd::Table => { table = false; None } _ => None, }, pulldown_cmark::Event::Text(text) if !metadata && !table => { #[cfg(feature = "highlighter")] if let Some(highlighter) = &mut highlighter { code.push_str(&text); for line in text.lines() { code_lines.push(Text::new( highlighter.highlight_line(line).to_vec(), )); } return None; } let span = Span::Standard { text: text.into_string(), strong, emphasis, strikethrough, link: link.clone(), code: false, }; spans.push(span); None } pulldown_cmark::Event::Code(code) if !metadata && !table => { let span = Span::Standard { text: code.into_string(), strong, emphasis, strikethrough, link: link.clone(), code: true, }; spans.push(span); None } pulldown_cmark::Event::SoftBreak if !metadata && !table => { spans.push(Span::Standard { text: String::from(" "), strikethrough, strong, emphasis, link: link.clone(), code: false, }); None } pulldown_cmark::Event::HardBreak if !metadata && !table => { spans.push(Span::Standard { text: String::from("\n"), strikethrough, strong, emphasis, link: link.clone(), code: false, }); None } _ => None, }) } /// Configuration controlling Markdown rendering in [`view`]. #[derive(Debug, Clone, Copy)] pub struct Settings { /// The base text size. pub text_size: Pixels, /// The text size of level 1 heading. pub h1_size: Pixels, /// The text size of level 2 heading. pub h2_size: Pixels, /// The text size of level 3 heading. pub h3_size: Pixels, /// The text size of level 4 heading. pub h4_size: Pixels, /// The text size of level 5 heading. pub h5_size: Pixels, /// The text size of level 6 heading. pub h6_size: Pixels, /// The text size used in code blocks. pub code_size: Pixels, /// The spacing to be used between elements. pub spacing: Pixels, /// The styling of the Markdown. pub style: Style, } impl Settings { /// Creates new [`Settings`] with default text size and the given [`Style`]. pub fn with_style(style: impl Into