From c02ae0c4a430994247e6fbc4318ac344ab89123c Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Tue, 4 Feb 2025 03:39:16 +0100 Subject: Introduce `view_with` customizers in `markdown` module --- widget/src/markdown.rs | 280 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 186 insertions(+), 94 deletions(-) (limited to 'widget') diff --git a/widget/src/markdown.rs b/widget/src/markdown.rs index 858ee281..628a10c6 100644 --- a/widget/src/markdown.rs +++ b/widget/src/markdown.rs @@ -189,6 +189,130 @@ pub enum Item { }, } +impl Item { + /// Displays a Markdown [`Item`] using the default, built-in look for its children. + pub fn view<'a, 'b, Theme, Renderer>( + &'b self, + settings: Settings, + style: Style, + index: usize, + ) -> Element<'a, Url, Theme, Renderer> + where + Theme: Catalog + 'a, + Renderer: core::text::Renderer + 'a, + { + self.view_with(index, settings, style, &DefaultView) + } + + /// Displays a Markdown [`Item`] using the given [`View`] for its children. + pub fn view_with<'a, 'b, Theme, Renderer>( + &'b self, + index: usize, + settings: Settings, + style: Style, + view: &dyn View<'a, 'b, Url, Theme, Renderer>, + ) -> Element<'a, Url, Theme, Renderer> + where + Theme: Catalog + 'a, + Renderer: core::text::Renderer + 'a, + { + let Settings { + text_size, + h1_size, + h2_size, + h3_size, + h4_size, + h5_size, + h6_size, + code_size, + spacing, + } = settings; + + match self { + Item::Heading(level, heading) => { + container(rich_text(heading.spans(style)).size(match level { + pulldown_cmark::HeadingLevel::H1 => h1_size, + pulldown_cmark::HeadingLevel::H2 => h2_size, + pulldown_cmark::HeadingLevel::H3 => h3_size, + pulldown_cmark::HeadingLevel::H4 => h4_size, + pulldown_cmark::HeadingLevel::H5 => h5_size, + pulldown_cmark::HeadingLevel::H6 => h6_size, + })) + .padding(padding::top(if index > 0 { + text_size / 2.0 + } else { + Pixels::ZERO + })) + .into() + } + Item::Paragraph(paragraph) => { + rich_text(paragraph.spans(style)).size(text_size).into() + } + Item::List { start: None, items } => { + column(items.iter().map(|items| { + row![ + text("•").size(text_size), + view_with( + items, + Settings { + spacing: settings.spacing * 0.6, + ..settings + }, + style, + view + ) + ] + .spacing(spacing) + .into() + })) + .spacing(spacing * 0.75) + .into() + } + Item::List { + start: Some(start), + items, + } => column(items.iter().enumerate().map(|(i, items)| { + row![ + text!("{}.", i as u64 + *start).size(text_size), + view_with( + items, + Settings { + spacing: settings.spacing * 0.6, + ..settings + }, + style, + view + ) + ] + .spacing(spacing) + .into() + })) + .spacing(spacing * 0.75) + .into(), + Item::CodeBlock(lines) => container( + scrollable( + container(column(lines.iter().map(|line| { + rich_text(line.spans(style)) + .font(Font::MONOSPACE) + .size(code_size) + .into() + }))) + .padding(spacing.0 / 2.0), + ) + .direction(scrollable::Direction::Horizontal( + scrollable::Scrollbar::default() + .width(spacing.0 / 2.0) + .scroller_width(spacing.0 / 2.0), + )), + ) + .width(Length::Fill) + .padding(spacing.0 / 2.0) + .class(Theme::code_block()) + .into(), + } + } +} + /// A bunch of parsed Markdown text. #[derive(Debug, Clone)] pub struct Text { @@ -900,100 +1024,68 @@ where Theme: Catalog + 'a, Renderer: core::text::Renderer + 'a, { - let Settings { - text_size, - h1_size, - h2_size, - h3_size, - h4_size, - h5_size, - h6_size, - code_size, - spacing, - } = settings; - - let blocks = items.into_iter().enumerate().map(|(i, item)| match item { - Item::Heading(level, heading) => { - container(rich_text(heading.spans(style)).size(match level { - pulldown_cmark::HeadingLevel::H1 => h1_size, - pulldown_cmark::HeadingLevel::H2 => h2_size, - pulldown_cmark::HeadingLevel::H3 => h3_size, - pulldown_cmark::HeadingLevel::H4 => h4_size, - pulldown_cmark::HeadingLevel::H5 => h5_size, - pulldown_cmark::HeadingLevel::H6 => h6_size, - })) - .padding(padding::top(if i > 0 { - text_size / 2.0 - } else { - Pixels::ZERO - })) - .into() - } - Item::Paragraph(paragraph) => { - rich_text(paragraph.spans(style)).size(text_size).into() - } - Item::List { start: None, items } => { - column(items.iter().map(|items| { - row![ - text("•").size(text_size), - view( - items, - Settings { - spacing: settings.spacing * 0.6, - ..settings - }, - style - ) - ] - .spacing(spacing) - .into() - })) - .spacing(spacing * 0.75) - .into() - } - Item::List { - start: Some(start), - items, - } => column(items.iter().enumerate().map(|(i, items)| { - row![ - text!("{}.", i as u64 + *start).size(text_size), - view( - items, - Settings { - spacing: settings.spacing * 0.6, - ..settings - }, - style - ) - ] - .spacing(spacing) - .into() - })) - .spacing(spacing * 0.75) - .into(), - Item::CodeBlock(lines) => container( - scrollable( - container(column(lines.iter().map(|line| { - rich_text(line.spans(style)) - .font(Font::MONOSPACE) - .size(code_size) - .into() - }))) - .padding(spacing.0 / 2.0), - ) - .direction(scrollable::Direction::Horizontal( - scrollable::Scrollbar::default() - .width(spacing.0 / 2.0) - .scroller_width(spacing.0 / 2.0), - )), - ) - .width(Length::Fill) - .padding(spacing.0 / 2.0) - .class(Theme::code_block()) - .into(), - }); - - Element::new(column(blocks).spacing(spacing)) + view_with(items, settings, style, &DefaultView) +} + +/// Runs [`view`] but with a custom [`View`] to turn an [`Item`] into +/// an [`Element`]. +/// +/// This is useful if you want to customize the look of certain Markdown +/// elements. +/// +/// You can use [`Item::view`] and [`Item::view_with`] for the default +/// look. +pub fn view_with<'a, 'b, Message, Theme, Renderer>( + items: impl IntoIterator, + settings: Settings, + style: Style, + view: &dyn View<'a, 'b, Message, Theme, Renderer>, +) -> Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: Catalog + 'a, + Renderer: core::text::Renderer + 'a, +{ + let blocks = items + .into_iter() + .enumerate() + .map(move |(i, item)| view.view(settings, style, item, i)); + + Element::new(column(blocks).spacing(settings.spacing)) +} + +/// A view strategy to display a Markdown [`Item`]. +pub trait View<'a, 'b, Message, Theme, Renderer> { + /// Displays a Markdown [`Item`] by projecting it into an [`Element`]. + /// + /// You can use [`Item::view`] and [`Item::view_with`] for the default + /// look. + fn view( + &self, + settings: Settings, + style: Style, + item: &'b Item, + index: usize, + ) -> Element<'a, Message, Theme, Renderer>; +} + +#[derive(Debug, Clone, Copy)] +struct DefaultView; + +impl<'a, 'b, Theme, Renderer> View<'a, 'b, Url, Theme, Renderer> for DefaultView +where + Theme: Catalog + 'a, + Renderer: core::text::Renderer + 'a, +{ + fn view( + &self, + settings: Settings, + style: Style, + item: &'b Item, + index: usize, + ) -> Element<'a, Url, Theme, Renderer> { + item.view(settings, style, index) + } } /// The theme catalog of Markdown items. -- cgit From 565599876172b3f56d86b119ae453b5bcd8949e1 Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Tue, 4 Feb 2025 07:53:56 +0100 Subject: Draft `Viewer` trait for `markdown` --- widget/src/helpers.rs | 6 +- widget/src/markdown.rs | 553 +++++++++++++++++++++++++++++++----------------- widget/src/pop.rs | 24 ++- widget/src/text/rich.rs | 57 +++-- 4 files changed, 428 insertions(+), 212 deletions(-) (limited to 'widget') diff --git a/widget/src/helpers.rs b/widget/src/helpers.rs index 4cba197d..2716d4c6 100644 --- a/widget/src/helpers.rs +++ b/widget/src/helpers.rs @@ -187,7 +187,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)),+]) @@ -1155,9 +1155,9 @@ where /// .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, diff --git a/widget/src/markdown.rs b/widget/src/markdown.rs index 628a10c6..d8d33763 100644 --- a/widget/src/markdown.rs +++ b/widget/src/markdown.rs @@ -47,6 +47,7 @@ //! } //! } //! ``` +#![allow(missing_docs)] use crate::core::border; use crate::core::font::{self, Font}; use crate::core::padding; @@ -144,6 +145,7 @@ impl Content { let mut state = State { leftover: String::new(), references: self.state.references.clone(), + images: HashSet::new(), highlighter: None, }; @@ -153,6 +155,7 @@ impl Content { self.items[*index] = item; } + self.state.images.extend(state.images.drain()); drop(state); } @@ -167,6 +170,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) -> impl Iterator { + self.state.images.iter() + } } /// A Markdown item. @@ -187,130 +195,13 @@ pub enum Item { /// The items of the list. items: Vec>, }, -} - -impl Item { - /// Displays a Markdown [`Item`] using the default, built-in look for its children. - pub fn view<'a, 'b, Theme, Renderer>( - &'b self, - settings: Settings, - style: Style, - index: usize, - ) -> Element<'a, Url, Theme, Renderer> - where - Theme: Catalog + 'a, - Renderer: core::text::Renderer + 'a, - { - self.view_with(index, settings, style, &DefaultView) - } - - /// Displays a Markdown [`Item`] using the given [`View`] for its children. - pub fn view_with<'a, 'b, Theme, Renderer>( - &'b self, - index: usize, - settings: Settings, - style: Style, - view: &dyn View<'a, 'b, Url, Theme, Renderer>, - ) -> Element<'a, Url, Theme, Renderer> - where - Theme: Catalog + 'a, - Renderer: core::text::Renderer + 'a, - { - let Settings { - text_size, - h1_size, - h2_size, - h3_size, - h4_size, - h5_size, - h6_size, - code_size, - spacing, - } = settings; - - match self { - Item::Heading(level, heading) => { - container(rich_text(heading.spans(style)).size(match level { - pulldown_cmark::HeadingLevel::H1 => h1_size, - pulldown_cmark::HeadingLevel::H2 => h2_size, - pulldown_cmark::HeadingLevel::H3 => h3_size, - pulldown_cmark::HeadingLevel::H4 => h4_size, - pulldown_cmark::HeadingLevel::H5 => h5_size, - pulldown_cmark::HeadingLevel::H6 => h6_size, - })) - .padding(padding::top(if index > 0 { - text_size / 2.0 - } else { - Pixels::ZERO - })) - .into() - } - Item::Paragraph(paragraph) => { - rich_text(paragraph.spans(style)).size(text_size).into() - } - Item::List { start: None, items } => { - column(items.iter().map(|items| { - row![ - text("•").size(text_size), - view_with( - items, - Settings { - spacing: settings.spacing * 0.6, - ..settings - }, - style, - view - ) - ] - .spacing(spacing) - .into() - })) - .spacing(spacing * 0.75) - .into() - } - Item::List { - start: Some(start), - items, - } => column(items.iter().enumerate().map(|(i, items)| { - row![ - text!("{}.", i as u64 + *start).size(text_size), - view_with( - items, - Settings { - spacing: settings.spacing * 0.6, - ..settings - }, - style, - view - ) - ] - .spacing(spacing) - .into() - })) - .spacing(spacing * 0.75) - .into(), - Item::CodeBlock(lines) => container( - scrollable( - container(column(lines.iter().map(|line| { - rich_text(line.spans(style)) - .font(Font::MONOSPACE) - .size(code_size) - .into() - }))) - .padding(spacing.0 / 2.0), - ) - .direction(scrollable::Direction::Horizontal( - scrollable::Scrollbar::default() - .width(spacing.0 / 2.0) - .scroller_width(spacing.0 / 2.0), - )), - ) - .width(Length::Fill) - .padding(spacing.0 / 2.0) - .class(Theme::code_block()) - .into(), - } - } + /// An image. + Image { + /// The destination URL of the image. + url: Url, + /// The title of the image. + title: Text, + }, } /// A bunch of parsed Markdown text. @@ -470,6 +361,7 @@ pub fn parse(markdown: &str) -> impl Iterator + '_ { struct State { leftover: String, references: HashMap, + images: HashSet, #[cfg(feature = "highlighter")] highlighter: Option, } @@ -560,6 +452,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>, @@ -575,7 +471,8 @@ fn parse_with<'a>( 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; @@ -616,10 +513,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(( @@ -627,16 +532,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 } }; @@ -673,31 +568,36 @@ fn parse_with<'a>( None } + pulldown_cmark::Tag::Image { dest_url, .. } + if !metadata && !table => + { + image = Url::parse(&dest_url).ok(); + 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( @@ -726,7 +626,7 @@ fn parse_with<'a>( } else { produce( state.borrow_mut(), - &mut lists, + &mut stack, Item::Paragraph(Text::new(spans.drain(..).collect())), source, ) @@ -748,7 +648,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, ) @@ -770,12 +670,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() { @@ -783,18 +687,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, @@ -802,6 +708,15 @@ fn parse_with<'a>( source, ) } + pulldown_cmark::TagEnd::Image if !metadata && !table => { + let url = image.take()?; + let title = Text::new(spans.drain(..).collect()); + + let state = state.borrow_mut(); + let _ = state.images.insert(url.clone()); + + produce(state, &mut stack, Item::Image { url, title }, source) + } pulldown_cmark::TagEnd::CodeBlock if !metadata && !table => { #[cfg(feature = "highlighter")] { @@ -810,7 +725,7 @@ fn parse_with<'a>( produce( state.borrow_mut(), - &mut lists, + &mut stack, Item::CodeBlock(code.drain(..).collect()), source, ) @@ -910,15 +825,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