diff --git a/Cargo.lock b/Cargo.lock index c9c7ddc5f3..5e14e0ff70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -770,9 +770,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.11" +version = "1.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4730490333d58093109dc02c23174c3f4d490998c3fed3cc8e82d57afedb9cf" +checksum = "755717a7de9ec452bf7f3f1a3099085deabd7f2962b861dae91ecd7a365903d2" dependencies = [ "jobserver", "libc", @@ -877,9 +877,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.27" +version = "4.5.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "769b0145982b4b48713e01ec42d61614425f27b7058bda7180a3a41f30104796" +checksum = "3e77c3243bd94243c03672cb5154667347c457ca271254724f9f393aee1c05ff" dependencies = [ "clap_builder", ] @@ -3299,7 +3299,10 @@ name = "markdown" version = "0.1.0" dependencies = [ "iced", + "image", "open", + "reqwest", + "tokio", ] [[package]] diff --git a/core/src/lib.rs b/core/src/lib.rs index c31a8da749..d5c221ac3a 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -84,3 +84,12 @@ pub use vector::Vector; pub use widget::Widget; pub use smol_str::SmolStr; + +/// A function that can _never_ be called. +/// +/// This is useful to turn generic types into anything +/// you want by coercing them into a type with no possible +/// values. +pub fn never(never: std::convert::Infallible) -> T { + match never {} +} diff --git a/core/src/padding.rs b/core/src/padding.rs index e26cdd9bde..9ec02e6d93 100644 --- a/core/src/padding.rs +++ b/core/src/padding.rs @@ -202,3 +202,9 @@ impl From for Size { Self::new(padding.horizontal(), padding.vertical()) } } + +impl From for Padding { + fn from(pixels: Pixels) -> Self { + Self::from(pixels.0) + } +} diff --git a/core/src/pixels.rs b/core/src/pixels.rs index a1ea0f1524..7d6267cfea 100644 --- a/core/src/pixels.rs +++ b/core/src/pixels.rs @@ -79,3 +79,11 @@ impl std::ops::Div for Pixels { Pixels(self.0 / rhs) } } + +impl std::ops::Div for Pixels { + type Output = Pixels; + + fn div(self, rhs: u32) -> Self { + Pixels(self.0 / rhs as f32) + } +} diff --git a/examples/changelog/src/main.rs b/examples/changelog/src/main.rs index f889e757eb..a1d0d79911 100644 --- a/examples/changelog/src/main.rs +++ b/examples/changelog/src/main.rs @@ -267,25 +267,21 @@ impl Generator { } => { let details = { let title = rich_text![ - span(&pull_request.title).size(24).link( - Message::OpenPullRequest(pull_request.id) - ), + span(&pull_request.title) + .size(24) + .link(pull_request.id), span(format!(" by {}", pull_request.author)) .font(Font { style: font::Style::Italic, ..Font::default() }), ] + .on_link_click(Message::OpenPullRequest) .font(Font::MONOSPACE); - let description = markdown::view( - description, - markdown::Settings::default(), - markdown::Style::from_palette( - self.theme().palette(), - ), - ) - .map(Message::UrlClicked); + let description = + markdown(description, self.theme()) + .map(Message::UrlClicked); let labels = row(pull_request.labels.iter().map(|label| { @@ -348,11 +344,11 @@ impl Generator { } else { container( scrollable( - markdown::view( + markdown( preview, - markdown::Settings::with_text_size(12), - markdown::Style::from_palette( - self.theme().palette(), + markdown::Settings::with_text_size( + 12, + self.theme(), ), ) .map(Message::UrlClicked), diff --git a/examples/markdown/Cargo.toml b/examples/markdown/Cargo.toml index fa6ced741e..7af1741bb2 100644 --- a/examples/markdown/Cargo.toml +++ b/examples/markdown/Cargo.toml @@ -7,6 +7,17 @@ publish = false [dependencies] iced.workspace = true -iced.features = ["markdown", "highlighter", "tokio", "debug"] +iced.features = ["markdown", "highlighter", "image", "tokio", "debug"] + +reqwest.version = "0.12" +reqwest.features = ["json"] + +image.workspace = true +tokio.workspace = true open = "5.3" + +# Disabled to keep amount of build dependencies low +# This can be re-enabled on demand +# [build-dependencies] +# iced_fontello = "0.13" diff --git a/examples/markdown/build.rs b/examples/markdown/build.rs new file mode 100644 index 0000000000..ecbb7666f3 --- /dev/null +++ b/examples/markdown/build.rs @@ -0,0 +1,5 @@ +pub fn main() { + // println!("cargo::rerun-if-changed=fonts/markdown-icons.toml"); + // iced_fontello::build("fonts/markdown-icons.toml") + // .expect("Build icons font"); +} diff --git a/examples/markdown/fonts/markdown-icons.toml b/examples/markdown/fonts/markdown-icons.toml new file mode 100644 index 0000000000..60c91d1752 --- /dev/null +++ b/examples/markdown/fonts/markdown-icons.toml @@ -0,0 +1,4 @@ +module = "icon" + +[glyphs] +copy = "fontawesome-docs" diff --git a/examples/markdown/fonts/markdown-icons.ttf b/examples/markdown/fonts/markdown-icons.ttf new file mode 100644 index 0000000000..013f03a5d1 Binary files /dev/null and b/examples/markdown/fonts/markdown-icons.ttf differ diff --git a/examples/markdown/src/icon.rs b/examples/markdown/src/icon.rs new file mode 100644 index 0000000000..cfe32541d9 --- /dev/null +++ b/examples/markdown/src/icon.rs @@ -0,0 +1,15 @@ +// Generated automatically by iced_fontello at build time. +// Do not edit manually. Source: ../fonts/markdown-icons.toml +// dcd2f0c969d603e2ee9237a4b70fa86b1a6e84d86f4689046d8fdd10440b06b9 +use iced::widget::{text, Text}; +use iced::Font; + +pub const FONT: &[u8] = include_bytes!("../fonts/markdown-icons.ttf"); + +pub fn copy<'a>() -> Text<'a> { + icon("\u{F0C5}") +} + +fn icon(codepoint: &str) -> Text<'_> { + text(codepoint).font(Font::with_name("markdown-icons")) +} diff --git a/examples/markdown/src/main.rs b/examples/markdown/src/main.rs index ba93ee1885..512d4b44d6 100644 --- a/examples/markdown/src/main.rs +++ b/examples/markdown/src/main.rs @@ -1,50 +1,79 @@ +mod icon; + +use iced::animation; +use iced::clipboard; use iced::highlighter; -use iced::time::{self, milliseconds}; +use iced::task; +use iced::time::{self, milliseconds, Instant}; use iced::widget::{ - self, hover, markdown, right, row, scrollable, text_editor, toggler, + self, button, center_x, container, horizontal_space, hover, image, + markdown, pop, right, row, scrollable, text_editor, toggler, }; -use iced::{Element, Fill, Font, Subscription, Task, Theme}; +use iced::window; +use iced::{Animation, Element, Fill, Font, Subscription, Task, Theme}; + +use std::collections::HashMap; +use std::io; +use std::sync::Arc; pub fn main() -> iced::Result { iced::application("Markdown - Iced", Markdown::update, Markdown::view) + .font(icon::FONT) .subscription(Markdown::subscription) .theme(Markdown::theme) .run_with(Markdown::new) } struct Markdown { - content: text_editor::Content, + content: markdown::Content, + raw: text_editor::Content, + images: HashMap, mode: Mode, theme: Theme, + now: Instant, } enum Mode { - Preview(Vec), - Stream { - pending: String, - parsed: markdown::Content, + Preview, + Stream { pending: String }, +} + +enum Image { + Loading { + _download: task::Handle, + }, + Ready { + handle: image::Handle, + fade_in: Animation, }, + #[allow(dead_code)] + Errored(Error), } #[derive(Debug, Clone)] enum Message { Edit(text_editor::Action), + Copy(String), LinkClicked(markdown::Url), + ImageShown(markdown::Url), + ImageDownloaded(markdown::Url, Result), ToggleStream(bool), NextToken, + Animate(Instant), } impl Markdown { fn new() -> (Self, Task) { const INITIAL_CONTENT: &str = include_str!("../overview.md"); - let theme = Theme::TokyoNight; - ( Self { - content: text_editor::Content::with_text(INITIAL_CONTENT), - mode: Mode::Preview(markdown::parse(INITIAL_CONTENT).collect()), - theme, + content: markdown::Content::parse(INITIAL_CONTENT), + raw: text_editor::Content::with_text(INITIAL_CONTENT), + images: HashMap::new(), + mode: Mode::Preview, + theme: Theme::TokyoNight, + now: Instant::now(), }, widget::focus_next(), ) @@ -55,26 +84,73 @@ impl Markdown { Message::Edit(action) => { let is_edit = action.is_edit(); - self.content.perform(action); + self.raw.perform(action); if is_edit { - self.mode = Mode::Preview( - markdown::parse(&self.content.text()).collect(), - ); + self.content = markdown::Content::parse(&self.raw.text()); + self.mode = Mode::Preview; + + let images = self.content.images(); + self.images.retain(|url, _image| images.contains(url)); } Task::none() } + Message::Copy(content) => clipboard::write(content), Message::LinkClicked(link) => { let _ = open::that_in_background(link.to_string()); Task::none() } + Message::ImageShown(url) => { + if self.images.contains_key(&url) { + return Task::none(); + } + + let (download_image, handle) = Task::future({ + let url = url.clone(); + + async move { + // Wait half a second for further editions before attempting download + tokio::time::sleep(milliseconds(500)).await; + download_image(url).await + } + }) + .abortable(); + + let _ = self.images.insert( + url.clone(), + Image::Loading { + _download: handle.abort_on_drop(), + }, + ); + + download_image.map(move |result| { + Message::ImageDownloaded(url.clone(), result) + }) + } + Message::ImageDownloaded(url, result) => { + let _ = self.images.insert( + url, + result + .map(|handle| Image::Ready { + handle, + fade_in: Animation::new(false) + .quick() + .easing(animation::Easing::EaseInOut) + .go(true), + }) + .unwrap_or_else(Image::Errored), + ); + + Task::none() + } Message::ToggleStream(enable_stream) => { if enable_stream { + self.content = markdown::Content::new(); + self.mode = Mode::Stream { - pending: self.content.text(), - parsed: markdown::Content::new(), + pending: self.raw.text(), }; scrollable::snap_to( @@ -82,24 +158,22 @@ impl Markdown { scrollable::RelativeOffset::END, ) } else { - self.mode = Mode::Preview( - markdown::parse(&self.content.text()).collect(), - ); + self.mode = Mode::Preview; Task::none() } } Message::NextToken => { match &mut self.mode { - Mode::Preview(_) => {} - Mode::Stream { pending, parsed } => { + Mode::Preview => {} + Mode::Stream { pending } => { if pending.is_empty() { - self.mode = Mode::Preview(parsed.items().to_vec()); + self.mode = Mode::Preview; } else { let mut tokens = pending.split(' '); if let Some(token) = tokens.next() { - parsed.push_str(&format!("{token} ")); + self.content.push_str(&format!("{token} ")); } *pending = tokens.collect::>().join(" "); @@ -107,13 +181,18 @@ impl Markdown { } } + Task::none() + } + Message::Animate(now) => { + self.now = now; + Task::none() } } } fn view(&self) -> Element { - let editor = text_editor(&self.content) + let editor = text_editor(&self.raw) .placeholder("Type your Markdown here...") .on_action(Message::Edit) .height(Fill) @@ -121,17 +200,14 @@ impl Markdown { .font(Font::MONOSPACE) .highlight("markdown", highlighter::Theme::Base16Ocean); - let items = match &self.mode { - Mode::Preview(items) => items.as_slice(), - Mode::Stream { parsed, .. } => parsed.items(), - }; - - let preview = markdown( - items, - markdown::Settings::default(), - markdown::Style::from_palette(self.theme.palette()), - ) - .map(Message::LinkClicked); + let preview = markdown::view_with( + self.content.items(), + &self.theme, + &CustomViewer { + images: &self.images, + now: self.now, + }, + ); row![ editor, @@ -159,11 +235,146 @@ impl Markdown { } fn subscription(&self) -> Subscription { - match self.mode { - Mode::Preview(_) => Subscription::none(), + let listen_stream = match self.mode { + Mode::Preview => Subscription::none(), Mode::Stream { .. } => { time::every(milliseconds(10)).map(|_| Message::NextToken) } + }; + + let animate = { + let is_animating = self.images.values().any(|image| match image { + Image::Ready { fade_in, .. } => fade_in.is_animating(self.now), + _ => false, + }); + + if is_animating { + window::frames().map(Message::Animate) + } else { + Subscription::none() + } + }; + + Subscription::batch([listen_stream, animate]) + } +} + +struct CustomViewer<'a> { + images: &'a HashMap, + now: Instant, +} + +impl<'a> markdown::Viewer<'a, Message> for CustomViewer<'a> { + fn on_link_click(url: markdown::Url) -> Message { + Message::LinkClicked(url) + } + + fn image( + &self, + _settings: markdown::Settings, + url: &'a markdown::Url, + _title: &'a str, + _alt: &markdown::Text, + ) -> Element<'a, Message> { + if let Some(Image::Ready { handle, fade_in }) = self.images.get(url) { + center_x( + image(handle) + .opacity(fade_in.interpolate(0.0, 1.0, self.now)) + .scale(fade_in.interpolate(1.2, 1.0, self.now)), + ) + .into() + } else { + pop(horizontal_space()) + .key(url.as_str()) + .on_show(|_size| Message::ImageShown(url.clone())) + .into() } } + + fn code_block( + &self, + settings: markdown::Settings, + _language: Option<&'a str>, + code: &'a str, + lines: &'a [markdown::Text], + ) -> Element<'a, Message> { + let code_block = + markdown::code_block(settings, lines, Message::LinkClicked); + + let copy = button(icon::copy().size(12)) + .padding(2) + .on_press_with(|| Message::Copy(code.to_owned())) + .style(button::text); + + hover( + code_block, + right(container(copy).style(container::dark)) + .padding(settings.spacing / 2), + ) + } +} + +async fn download_image(url: markdown::Url) -> Result { + use std::io; + use tokio::task; + + println!("Trying to download image: {url}"); + + let client = reqwest::Client::new(); + + let bytes = client + .get(url) + .send() + .await? + .error_for_status()? + .bytes() + .await?; + + let image = task::spawn_blocking(move || { + Ok::<_, Error>( + ::image::ImageReader::new(io::Cursor::new(bytes)) + .with_guessed_format()? + .decode()? + .to_rgba8(), + ) + }) + .await??; + + Ok(image::Handle::from_rgba( + image.width(), + image.height(), + image.into_raw(), + )) +} + +#[derive(Debug, Clone)] +pub enum Error { + RequestFailed(Arc), + IOFailed(Arc), + JoinFailed(Arc), + ImageDecodingFailed(Arc<::image::ImageError>), +} + +impl From for Error { + fn from(error: reqwest::Error) -> Self { + Self::RequestFailed(Arc::new(error)) + } +} + +impl From for Error { + fn from(error: io::Error) -> Self { + Self::IOFailed(Arc::new(error)) + } +} + +impl From for Error { + fn from(error: tokio::task::JoinError) -> Self { + Self::JoinFailed(Arc::new(error)) + } +} + +impl From<::image::ImageError> for Error { + fn from(error: ::image::ImageError) -> Self { + Self::ImageDecodingFailed(Arc::new(error)) + } } diff --git a/src/lib.rs b/src/lib.rs index eec844bc76..849b51e9a2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -505,9 +505,9 @@ pub use crate::core::gradient; pub use crate::core::padding; pub use crate::core::theme; pub use crate::core::{ - Alignment, Animation, Background, Border, Color, ContentFit, Degrees, - Gradient, Length, Padding, Pixels, Point, Radians, Rectangle, Rotation, - Settings, Shadow, Size, Theme, Transformation, Vector, + never, Alignment, Animation, Background, Border, Color, ContentFit, + Degrees, Gradient, Length, Padding, Pixels, Point, Radians, Rectangle, + Rotation, Settings, Shadow, Size, Theme, Transformation, Vector, }; pub use crate::runtime::exit; pub use iced_futures::Subscription; diff --git a/widget/src/helpers.rs b/widget/src/helpers.rs index 4cba197d06..42d0f49982 100644 --- a/widget/src/helpers.rs +++ b/widget/src/helpers.rs @@ -167,7 +167,7 @@ macro_rules! text { /// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; /// use iced::font; /// use iced::widget::{rich_text, span}; -/// use iced::{color, Font}; +/// use iced::{color, never, Font}; /// /// #[derive(Debug, Clone)] /// enum Message { @@ -177,9 +177,10 @@ macro_rules! text { /// fn view(state: &State) -> Element<'_, Message> { /// rich_text![ /// span("I am red!").color(color!(0xff0000)), -/// " ", +/// span(" "), /// span("And I am bold!").font(Font { weight: font::Weight::Bold, ..Font::default() }), /// ] +/// .on_link_click(never) /// .size(20) /// .into() /// } @@ -187,7 +188,7 @@ macro_rules! text { #[macro_export] macro_rules! rich_text { () => ( - $crate::Column::new() + $crate::text::Rich::new() ); ($($x:expr),+ $(,)?) => ( $crate::text::Rich::from_iter([$($crate::text::Span::from($x)),+]) @@ -1138,10 +1139,11 @@ where /// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; /// use iced::font; /// use iced::widget::{rich_text, span}; -/// use iced::{color, Font}; +/// use iced::{color, never, Font}; /// /// #[derive(Debug, Clone)] /// enum Message { +/// LinkClicked(&'static str), /// // ... /// } /// @@ -1151,13 +1153,14 @@ where /// span(" "), /// span("And I am bold!").font(Font { weight: font::Weight::Bold, ..Font::default() }), /// ]) +/// .on_link_click(never) /// .size(20) /// .into() /// } /// ``` -pub fn rich_text<'a, Link, Theme, Renderer>( +pub fn rich_text<'a, Link, Message, Theme, Renderer>( spans: impl AsRef<[text::Span<'a, Link, Renderer::Font>]> + 'a, -) -> text::Rich<'a, Link, Theme, Renderer> +) -> text::Rich<'a, Link, Message, Theme, Renderer> where Link: Clone + 'static, Theme: text::Catalog + 'a, @@ -1181,7 +1184,7 @@ where /// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; /// use iced::font; /// use iced::widget::{rich_text, span}; -/// use iced::{color, Font}; +/// use iced::{color, never, Font}; /// /// #[derive(Debug, Clone)] /// enum Message { @@ -1194,6 +1197,7 @@ where /// " ", /// span("And I am bold!").font(Font { weight: font::Weight::Bold, ..Font::default() }), /// ] +/// .on_link_click(never) /// .size(20) /// .into() /// } diff --git a/widget/src/markdown.rs b/widget/src/markdown.rs index 858ee281f3..b69c663e7c 100644 --- a/widget/src/markdown.rs +++ b/widget/src/markdown.rs @@ -29,13 +29,9 @@ //! } //! //! fn view(&self) -> Element<'_, Message> { -//! markdown::view( -//! &self.markdown, -//! markdown::Settings::default(), -//! markdown::Style::from_palette(Theme::TokyoNightStorm.palette()), -//! ) -//! .map(Message::LinkClicked) -//! .into() +//! markdown::view(&self.markdown, Theme::TokyoNight) +//! .map(Message::LinkClicked) +//! .into() //! } //! //! fn update(state: &mut State, message: Message) { @@ -59,6 +55,7 @@ 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; @@ -144,6 +141,7 @@ impl Content { let mut state = State { leftover: String::new(), references: self.state.references.clone(), + images: HashSet::new(), highlighter: None, }; @@ -153,6 +151,7 @@ impl Content { self.items[*index] = item; } + self.state.images.extend(state.images.drain()); drop(state); } @@ -167,6 +166,11 @@ impl Content { 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. @@ -179,7 +183,14 @@ pub enum Item { /// A code block. /// /// You can enable the `highlighter` feature for syntax highlighting. - CodeBlock(Vec), + 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. @@ -187,6 +198,15 @@ pub enum Item { /// 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. @@ -319,13 +339,9 @@ impl Span { /// } /// /// fn view(&self) -> Element<'_, Message> { -/// markdown::view( -/// &self.markdown, -/// markdown::Settings::default(), -/// markdown::Style::from_palette(Theme::TokyoNightStorm.palette()), -/// ) -/// .map(Message::LinkClicked) -/// .into() +/// markdown::view(&self.markdown, Theme::TokyoNight) +/// .map(Message::LinkClicked) +/// .into() /// } /// /// fn update(state: &mut State, message: Message) { @@ -346,6 +362,7 @@ pub fn parse(markdown: &str) -> impl Iterator + '_ { struct State { leftover: String, references: HashMap, + images: HashSet, #[cfg(feature = "highlighter")] highlighter: Option, } @@ -367,7 +384,7 @@ impl Highlighter { parser: iced_highlighter::Stream::new( &iced_highlighter::Settings { theme: iced_highlighter::Theme::Base16Ocean, - token: language.to_string(), + token: language.to_owned(), }, ), language: language.to_owned(), @@ -436,6 +453,10 @@ 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>, @@ -444,14 +465,17 @@ fn parse_with<'a>( let broken_links = Rc::new(RefCell::new(HashSet::new())); let mut spans = Vec::new(); - let mut code = 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 lists = Vec::new(); + let mut image = None; + let mut stack = Vec::new(); #[cfg(feature = "highlighter")] let mut highlighter = None; @@ -476,7 +500,7 @@ fn parse_with<'a>( )) } else { let _ = RefCell::borrow_mut(&broken_links) - .insert(broken_link.reference.to_string()); + .insert(broken_link.reference.into_string()); None } @@ -492,10 +516,18 @@ fn parse_with<'a>( } let produce = move |state: &mut State, - lists: &mut Vec, + stack: &mut Vec, item, source: Range| { - if lists.is_empty() { + 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(( @@ -503,16 +535,6 @@ fn parse_with<'a>( &markdown[source.start..source.end], broken_links.take(), )) - } else { - lists - .last_mut() - .expect("list context") - .items - .last_mut() - .expect("item context") - .push(item); - - None } }; @@ -549,35 +571,42 @@ fn parse_with<'a>( 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 lists, + &mut stack, Item::Paragraph(Text::new(spans.drain(..).collect())), source, ) }; - lists.push(List { + stack.push(Scope::List(List { start: first_item, items: Vec::new(), - }); + })); prev } pulldown_cmark::Tag::Item => { - lists - .last_mut() - .expect("list context") - .items - .push(Vec::new()); + if let Some(Scope::List(list)) = stack.last_mut() { + list.items.push(Vec::new()); + } + None } pulldown_cmark::Tag::CodeBlock( - pulldown_cmark::CodeBlockKind::Fenced(_language), + pulldown_cmark::CodeBlockKind::Fenced(language), ) if !metadata && !table => { #[cfg(feature = "highlighter")] { @@ -587,9 +616,9 @@ fn parse_with<'a>( .highlighter .take() .filter(|highlighter| { - highlighter.language == _language.as_ref() + highlighter.language == language.as_ref() }) - .unwrap_or_else(|| Highlighter::new(&_language)); + .unwrap_or_else(|| Highlighter::new(&language)); highlighter.prepare(); @@ -597,12 +626,15 @@ fn parse_with<'a>( }); } + code_language = + (!language.is_empty()).then(|| language.into_string()); + let prev = if spans.is_empty() { None } else { produce( state.borrow_mut(), - &mut lists, + &mut stack, Item::Paragraph(Text::new(spans.drain(..).collect())), source, ) @@ -624,7 +656,7 @@ fn parse_with<'a>( pulldown_cmark::TagEnd::Heading(level) if !metadata && !table => { produce( state.borrow_mut(), - &mut lists, + &mut stack, Item::Heading(level, Text::new(spans.drain(..).collect())), source, ) @@ -646,12 +678,16 @@ fn parse_with<'a>( None } pulldown_cmark::TagEnd::Paragraph if !metadata && !table => { - produce( - state.borrow_mut(), - &mut lists, - Item::Paragraph(Text::new(spans.drain(..).collect())), - source, - ) + 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() { @@ -659,18 +695,20 @@ fn parse_with<'a>( } else { produce( state.borrow_mut(), - &mut lists, + &mut stack, Item::Paragraph(Text::new(spans.drain(..).collect())), source, ) } } pulldown_cmark::TagEnd::List(_) if !metadata && !table => { - let list = lists.pop().expect("list context"); + let scope = stack.pop()?; + + let Scope::List(list) = scope; produce( state.borrow_mut(), - &mut lists, + &mut stack, Item::List { start: list.start, items: list.items, @@ -678,6 +716,20 @@ fn parse_with<'a>( 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")] { @@ -686,8 +738,12 @@ fn parse_with<'a>( produce( state.borrow_mut(), - &mut lists, - Item::CodeBlock(code.drain(..).collect()), + &mut stack, + Item::CodeBlock { + language: code_language.take(), + code: mem::take(&mut code), + lines: code_lines.drain(..).collect(), + }, source, ) } @@ -704,8 +760,10 @@ fn parse_with<'a>( 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.push(Text::new( + code_lines.push(Text::new( highlighter.highlight_line(line).to_vec(), )); } @@ -786,15 +844,25 @@ pub struct Settings { 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