diff options
author | 2025-02-04 07:53:56 +0100 | |
---|---|---|
committer | 2025-02-04 07:53:56 +0100 | |
commit | 565599876172b3f56d86b119ae453b5bcd8949e1 (patch) | |
tree | 5353cb71aa595fc5ef3445e104b4776b8cc61ccb | |
parent | c02ae0c4a430994247e6fbc4318ac344ab89123c (diff) | |
download | iced-565599876172b3f56d86b119ae453b5bcd8949e1.tar.gz iced-565599876172b3f56d86b119ae453b5bcd8949e1.tar.bz2 iced-565599876172b3f56d86b119ae453b5bcd8949e1.zip |
Draft `Viewer` trait for `markdown`
-rw-r--r-- | Cargo.lock | 3 | ||||
-rw-r--r-- | core/src/padding.rs | 6 | ||||
-rw-r--r-- | examples/changelog/src/main.rs | 26 | ||||
-rw-r--r-- | examples/markdown/Cargo.toml | 8 | ||||
-rw-r--r-- | examples/markdown/src/main.rs | 139 | ||||
-rw-r--r-- | widget/src/helpers.rs | 6 | ||||
-rw-r--r-- | widget/src/markdown.rs | 553 | ||||
-rw-r--r-- | widget/src/pop.rs | 24 | ||||
-rw-r--r-- | widget/src/text/rich.rs | 57 |
9 files changed, 588 insertions, 234 deletions
@@ -3299,7 +3299,10 @@ name = "markdown" version = "0.1.0" dependencies = [ "iced", + "image", "open", + "reqwest", + "tokio", ] [[package]] diff --git a/core/src/padding.rs b/core/src/padding.rs index e26cdd9b..9ec02e6d 100644 --- a/core/src/padding.rs +++ b/core/src/padding.rs @@ -202,3 +202,9 @@ impl From<Padding> for Size { Self::new(padding.horizontal(), padding.vertical()) } } + +impl From<Pixels> for Padding { + fn from(pixels: Pixels) -> Self { + Self::from(pixels.0) + } +} diff --git a/examples/changelog/src/main.rs b/examples/changelog/src/main.rs index f889e757..a6528ce9 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_clicked(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::view(&self.theme(), description) + .map(Message::UrlClicked); let labels = row(pull_request.labels.iter().map(|label| { @@ -349,11 +345,11 @@ impl Generator { container( scrollable( markdown::view( - preview, - markdown::Settings::with_text_size(12), - markdown::Style::from_palette( - self.theme().palette(), + markdown::Settings::with_text_size( + 12, + &self.theme(), ), + preview, ) .map(Message::UrlClicked), ) diff --git a/examples/markdown/Cargo.toml b/examples/markdown/Cargo.toml index fa6ced74..4711b1c4 100644 --- a/examples/markdown/Cargo.toml +++ b/examples/markdown/Cargo.toml @@ -7,6 +7,12 @@ 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" diff --git a/examples/markdown/src/main.rs b/examples/markdown/src/main.rs index ba93ee18..29625d79 100644 --- a/examples/markdown/src/main.rs +++ b/examples/markdown/src/main.rs @@ -1,10 +1,17 @@ use iced::highlighter; use iced::time::{self, milliseconds}; use iced::widget::{ - self, hover, markdown, right, row, scrollable, text_editor, toggler, + self, center_x, horizontal_space, hover, image, markdown, pop, right, row, + scrollable, text_editor, toggler, }; use iced::{Element, Fill, Font, Subscription, Task, Theme}; +use tokio::task; + +use std::collections::HashMap; +use std::io; +use std::sync::Arc; + pub fn main() -> iced::Result { iced::application("Markdown - Iced", Markdown::update, Markdown::view) .subscription(Markdown::subscription) @@ -14,6 +21,7 @@ pub fn main() -> iced::Result { struct Markdown { content: text_editor::Content, + images: HashMap<markdown::Url, Image>, mode: Mode, theme: Theme, } @@ -26,10 +34,19 @@ enum Mode { }, } +enum Image { + Loading, + Ready(image::Handle), + #[allow(dead_code)] + Errored(Error), +} + #[derive(Debug, Clone)] enum Message { Edit(text_editor::Action), LinkClicked(markdown::Url), + ImageShown(markdown::Url), + ImageDownloaded(markdown::Url, Result<image::Handle, Error>), ToggleStream(bool), NextToken, } @@ -43,6 +60,7 @@ impl Markdown { ( Self { content: text_editor::Content::with_text(INITIAL_CONTENT), + images: HashMap::new(), mode: Mode::Preview(markdown::parse(INITIAL_CONTENT).collect()), theme, }, @@ -70,6 +88,25 @@ impl Markdown { Task::none() } + Message::ImageShown(url) => { + if self.images.contains_key(&url) { + return Task::none(); + } + + let _ = self.images.insert(url.clone(), Image::Loading); + + Task::perform(download_image(url.clone()), move |result| { + Message::ImageDownloaded(url.clone(), result) + }) + } + Message::ImageDownloaded(url, result) => { + let _ = self.images.insert( + url, + result.map(Image::Ready).unwrap_or_else(Image::Errored), + ); + + Task::none() + } Message::ToggleStream(enable_stream) => { if enable_stream { self.mode = Mode::Stream { @@ -126,12 +163,13 @@ impl Markdown { Mode::Stream { parsed, .. } => parsed.items(), }; - let preview = markdown( + let preview = markdown::view_with( + &MarkdownViewer { + images: &self.images, + }, + &self.theme, items, - markdown::Settings::default(), - markdown::Style::from_palette(self.theme.palette()), - ) - .map(Message::LinkClicked); + ); row![ editor, @@ -167,3 +205,92 @@ impl Markdown { } } } + +struct MarkdownViewer<'a> { + images: &'a HashMap<markdown::Url, Image>, +} + +impl<'a> markdown::Viewer<'a, Message> for MarkdownViewer<'a> { + fn on_link_clicked(url: markdown::Url) -> Message { + Message::LinkClicked(url) + } + + fn image( + &self, + _settings: markdown::Settings, + _title: &markdown::Text, + url: &'a markdown::Url, + ) -> Element<'a, Message> { + if let Some(Image::Ready(handle)) = self.images.get(url) { + center_x(image(handle)).into() + } else { + pop(horizontal_space().width(0)) + .key(url.as_str()) + .on_show(|_size| Message::ImageShown(url.clone())) + .into() + } + } +} + +async fn download_image(url: markdown::Url) -> Result<image::Handle, Error> { + use std::io; + use tokio::task; + + 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<reqwest::Error>), + IOFailed(Arc<io::Error>), + JoinFailed(Arc<task::JoinError>), + ImageDecodingFailed(Arc<::image::ImageError>), +} + +impl From<reqwest::Error> for Error { + fn from(error: reqwest::Error) -> Self { + Self::RequestFailed(Arc::new(error)) + } +} + +impl From<io::Error> for Error { + fn from(error: io::Error) -> Self { + Self::IOFailed(Arc::new(error)) + } +} + +impl From<task::JoinError> for Error { + fn from(error: 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/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<Item = &Url> { + self.state.images.iter() + } } /// A Markdown item. @@ -187,130 +195,13 @@ pub enum Item { /// The items of the list. items: Vec<Vec<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<Font = Font> + '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<Font = Font> + '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<Item = Item> + '_ { struct State { leftover: String, references: HashMap<String, String>, + images: HashSet<Url>, #[cfg(feature = "highlighter")] highlighter: Option<Highlighter>, } @@ -560,6 +452,10 @@ fn parse_with<'a>( mut state: impl BorrowMut<State> + 'a, markdown: &'a str, ) -> impl Iterator<Item = (Item, &'a str, HashSet<String>)> + 'a { + enum Scope { + List(List), + } + struct List { start: Option<u64>, items: Vec<Vec<Item>>, @@ -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<List>, + stack: &mut Vec<Scope>, item, source: Range<usize>| { - 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<Style>) -> Self { + Self::with_text_size(16, style) + } + /// Creates new [`Settings`] with the given base text size in [`Pixels`]. /// /// Heading levels will be adjusted automatically. Specifically, /// the first level will be twice the base size, and then every level /// after that will be 25% smaller. - pub fn with_text_size(text_size: impl Into<Pixels>) -> Self { + pub fn with_text_size( + text_size: impl Into<Pixels>, + style: impl Into<Style>, + ) -> Self { let text_size = text_size.into(); Self { @@ -931,13 +856,14 @@ impl Settings { h6_size: text_size, code_size: text_size * 0.75, spacing: text_size * 0.875, + style: style.into(), } } } -impl Default for Settings { - fn default() -> Self { - Self::with_text_size(16) +impl From<&Theme> for Settings { + fn from(theme: &Theme) -> Self { + Self::with_style(Style::from(theme)) } } @@ -969,6 +895,18 @@ impl Style { } } +impl From<theme::Palette> for Style { + fn from(palette: theme::Palette) -> Self { + Self::from_palette(palette) + } +} + +impl From<&Theme> for Style { + fn from(theme: &Theme) -> Self { + Self::from_palette(theme.palette()) + } +} + /// Display a bunch of Markdown items. /// /// You can obtain the items with [`parse`]. @@ -1015,16 +953,15 @@ impl Style { /// } /// } /// ``` -pub fn view<'a, 'b, Theme, Renderer>( - items: impl IntoIterator<Item = &'b Item>, - settings: Settings, - style: Style, +pub fn view<'a, Theme, Renderer>( + settings: impl Into<Settings>, + items: impl IntoIterator<Item = &'a Item>, ) -> Element<'a, Url, Theme, Renderer> where Theme: Catalog + 'a, Renderer: core::text::Renderer<Font = Font> + 'a, { - view_with(items, settings, style, &DefaultView) + view_with(&DefaultViewer, settings, items) } /// Runs [`view`] but with a custom [`View`] to turn an [`Item`] into @@ -1035,56 +972,288 @@ where /// /// 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<Item = &'b Item>, - settings: Settings, - style: Style, - view: &dyn View<'a, 'b, Message, Theme, Renderer>, +pub fn view_with<'a, Message, Theme, Renderer>( + viewer: &impl Viewer<'a, Message, Theme, Renderer>, + settings: impl Into<Settings>, + items: impl IntoIterator<Item = &'a Item>, ) -> Element<'a, Message, Theme, Renderer> where Message: 'a, Theme: Catalog + 'a, Renderer: core::text::Renderer<Font = Font> + 'a, { + let settings = settings.into(); + let blocks = items .into_iter() .enumerate() - .map(move |(i, item)| view.view(settings, style, item, i)); + .map(|(i, item_)| item(viewer, settings, 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( +pub fn item<'a, Message, Theme, Renderer>( + viewer: &impl Viewer<'a, Message, Theme, Renderer>, + settings: Settings, + item: &'a Item, + index: usize, +) -> Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: Catalog + 'a, + Renderer: core::text::Renderer<Font = Font> + 'a, +{ + match item { + Item::Image { title, url } => viewer.image(settings, title, url), + Item::Heading(level, text) => { + viewer.heading(settings, index, level, text) + } + Item::Paragraph(text) => viewer.paragraph(settings, text), + Item::CodeBlock(lines) => viewer.code_block(settings, lines), + Item::List { start: None, items } => { + viewer.unordered_list(settings, items) + } + Item::List { + start: Some(start), + items, + } => viewer.ordered_list(settings, *start, items), + } +} + +pub fn heading<'a, Message, Theme, Renderer>( + settings: Settings, + index: usize, + level: &'a HeadingLevel, + text: &'a Text, + on_link_clicked: impl Fn(Url) -> Message + 'a, +) -> Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: Catalog + 'a, + Renderer: core::text::Renderer<Font = Font> + 'a, +{ + let Settings { + h1_size, + h2_size, + h3_size, + h4_size, + h5_size, + h6_size, + text_size, + .. + } = settings; + + container( + rich_text(text.spans(settings.style)) + .on_link_clicked(on_link_clicked) + .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() +} + +pub fn paragraph<'a, Message, Theme, Renderer>( + settings: Settings, + text: &'a Text, + on_link_clicked: impl Fn(Url) -> Message + 'a, +) -> Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: Catalog + 'a, + Renderer: core::text::Renderer<Font = Font> + 'a, +{ + rich_text(text.spans(settings.style)) + .size(settings.text_size) + .on_link_clicked(on_link_clicked) + .into() +} + +pub fn unordered_list<'a, Message, Theme, Renderer>( + viewer: &impl Viewer<'a, Message, Theme, Renderer>, + settings: Settings, + items: &'a [Vec<Item>], +) -> Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: Catalog + 'a, + Renderer: core::text::Renderer<Font = Font> + 'a, +{ + column(items.iter().map(|items| { + row![ + text("•").size(settings.text_size), + view_with( + viewer, + Settings { + spacing: settings.spacing * 0.6, + ..settings + }, + items, + ) + ] + .spacing(settings.spacing) + .into() + })) + .spacing(settings.spacing * 0.75) + .padding([0.0, settings.spacing.0]) + .into() +} + +pub fn ordered_list<'a, Message, Theme, Renderer>( + viewer: &impl Viewer<'a, Message, Theme, Renderer>, + settings: Settings, + start: u64, + items: &'a [Vec<Item>], +) -> Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: Catalog + 'a, + Renderer: core::text::Renderer<Font = Font> + 'a, +{ + column(items.iter().enumerate().map(|(i, items)| { + row![ + text!("{}.", i as u64 + start).size(settings.text_size), + view_with( + viewer, + Settings { + spacing: settings.spacing * 0.6, + ..settings + }, + items, + ) + ] + .spacing(settings.spacing) + .into() + })) + .spacing(settings.spacing * 0.75) + .padding([0.0, settings.spacing.0]) + .into() +} + +pub fn code_block<'a, Message, Theme, Renderer>( + settings: Settings, + lines: &'a [Text], + on_link_clicked: impl Fn(Url) -> Message + Clone + 'a, +) -> Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: Catalog + 'a, + Renderer: core::text::Renderer<Font = Font> + 'a, +{ + container( + scrollable( + container(column(lines.iter().map(|line| { + rich_text(line.spans(settings.style)) + .on_link_clicked(on_link_clicked.clone()) + .font(Font::MONOSPACE) + .size(settings.code_size) + .into() + }))) + .padding(settings.spacing.0 / 2.0), + ) + .direction(scrollable::Direction::Horizontal( + scrollable::Scrollbar::default() + .width(settings.spacing.0 / 2.0) + .scroller_width(settings.spacing.0 / 2.0), + )), + ) + .width(Length::Fill) + .padding(settings.spacing.0 / 2.0) + .class(Theme::code_block()) + .into() +} + +/// A view strategy to display a Markdown [`Item`].j +pub trait Viewer<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> +where + Self: Sized + 'a, + Message: 'a, + Theme: Catalog + 'a, + Renderer: core::text::Renderer<Font = Font> + 'a, +{ + fn on_link_clicked(url: Url) -> Message; + + fn image( + &self, + settings: Settings, + title: &Text, + url: &'a Url, + ) -> Element<'a, Message, Theme, Renderer> { + let _url = url; + + container( + rich_text(title.spans(settings.style)) + .on_link_clicked(Self::on_link_clicked), + ) + .padding(settings.spacing.0) + .class(Theme::code_block()) + .into() + } + + fn heading( &self, settings: Settings, - style: Style, - item: &'b Item, index: usize, - ) -> Element<'a, Message, Theme, Renderer>; + level: &'a HeadingLevel, + text: &'a Text, + ) -> Element<'a, Message, Theme, Renderer> { + heading(settings, index, level, text, Self::on_link_clicked) + } + + fn paragraph( + &self, + settings: Settings, + text: &'a Text, + ) -> Element<'a, Message, Theme, Renderer> { + paragraph(settings, text, Self::on_link_clicked) + } + + fn code_block( + &self, + settings: Settings, + lines: &'a [Text], + ) -> Element<'a, Message, Theme, Renderer> { + code_block(settings, lines, Self::on_link_clicked) + } + + fn unordered_list( + &self, + settings: Settings, + items: &'a [Vec<Item>], + ) -> Element<'a, Message, Theme, Renderer> { + unordered_list(self, settings, items) + } + + fn ordered_list( + &self, + settings: Settings, + start: u64, + items: &'a [Vec<Item>], + ) -> Element<'a, Message, Theme, Renderer> { + ordered_list(self, settings, start, items) + } } #[derive(Debug, Clone, Copy)] -struct DefaultView; +struct DefaultViewer; -impl<'a, 'b, Theme, Renderer> View<'a, 'b, Url, Theme, Renderer> for DefaultView +impl<'a, Theme, Renderer> Viewer<'a, Url, Theme, Renderer> for DefaultViewer where Theme: Catalog + 'a, Renderer: core::text::Renderer<Font = Font> + 'a, { - fn view( - &self, - settings: Settings, - style: Style, - item: &'b Item, - index: usize, - ) -> Element<'a, Url, Theme, Renderer> { - item.view(settings, style, index) + fn on_link_clicked(url: Url) -> Url { + url } } diff --git a/widget/src/pop.rs b/widget/src/pop.rs index 6e9df4be..950371ea 100644 --- a/widget/src/pop.rs +++ b/widget/src/pop.rs @@ -3,6 +3,7 @@ use crate::core::layout; use crate::core::mouse; use crate::core::overlay; use crate::core::renderer; +use crate::core::text; use crate::core::widget; use crate::core::widget::tree::{self, Tree}; use crate::core::window; @@ -17,6 +18,7 @@ use crate::core::{ #[allow(missing_debug_implementations)] pub struct Pop<'a, Message, Theme = crate::Theme, Renderer = crate::Renderer> { content: Element<'a, Message, Theme, Renderer>, + key: Option<text::Fragment<'a>>, on_show: Option<Box<dyn Fn(Size) -> Message + 'a>>, on_resize: Option<Box<dyn Fn(Size) -> Message + 'a>>, on_hide: Option<Message>, @@ -34,6 +36,7 @@ where ) -> Self { Self { content: content.into(), + key: None, on_show: None, on_resize: None, on_hide: None, @@ -66,6 +69,14 @@ where self } + /// Sets the key of the [`Pop`] widget, for continuity. + /// + /// If the key changes, the [`Pop`] widget will trigger again. + pub fn key(mut self, key: impl text::IntoFragment<'a>) -> Self { + self.key = Some(key.into_fragment()); + self + } + /// Sets the distance in [`Pixels`] to use in anticipation of the /// content popping into view. /// @@ -77,10 +88,11 @@ where } } -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Default)] struct State { has_popped_in: bool, last_size: Option<Size>, + last_key: Option<String>, } impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer> @@ -118,8 +130,16 @@ where ) { if let Event::Window(window::Event::RedrawRequested(_)) = &event { let state = tree.state.downcast_mut::<State>(); - let bounds = layout.bounds(); + if state.has_popped_in + && state.last_key.as_deref() != self.key.as_deref() + { + state.has_popped_in = false; + state.last_key = + self.key.as_ref().cloned().map(text::Fragment::into_owned); + } + + let bounds = layout.bounds(); let top_left_distance = viewport.distance(bounds.position()); let bottom_right_distance = viewport diff --git a/widget/src/text/rich.rs b/widget/src/text/rich.rs index a9e544d1..02348b54 100644 --- a/widget/src/text/rich.rs +++ b/widget/src/text/rich.rs @@ -14,8 +14,13 @@ use crate::core::{ /// A bunch of [`Rich`] text. #[allow(missing_debug_implementations)] -pub struct Rich<'a, Link, Theme = crate::Theme, Renderer = crate::Renderer> -where +pub struct Rich< + 'a, + Link, + Message, + Theme = crate::Theme, + Renderer = crate::Renderer, +> where Link: Clone + 'static, Theme: Catalog, Renderer: core::text::Renderer, @@ -31,9 +36,11 @@ where wrapping: Wrapping, class: Theme::Class<'a>, hovered_link: Option<usize>, + on_link_clicked: Option<Box<dyn Fn(Link) -> Message + 'a>>, } -impl<'a, Link, Theme, Renderer> Rich<'a, Link, Theme, Renderer> +impl<'a, Link, Message, Theme, Renderer> + Rich<'a, Link, Message, Theme, Renderer> where Link: Clone + 'static, Theme: Catalog, @@ -54,6 +61,7 @@ where wrapping: Wrapping::default(), class: Theme::default(), hovered_link: None, + on_link_clicked: None, } } @@ -127,6 +135,16 @@ where self } + /// Sets the message that will be produced when a link of the [`Rich`] text + /// is clicked. + pub fn on_link_clicked( + mut self, + on_link_clicked: impl Fn(Link) -> Message + 'a, + ) -> Self { + self.on_link_clicked = Some(Box::new(on_link_clicked)); + self + } + /// Sets the default style of the [`Rich`] text. #[must_use] pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self @@ -164,7 +182,8 @@ where } } -impl<'a, Link, Theme, Renderer> Default for Rich<'a, Link, Theme, Renderer> +impl<'a, Link, Message, Theme, Renderer> Default + for Rich<'a, Link, Message, Theme, Renderer> where Link: Clone + 'a, Theme: Catalog, @@ -182,8 +201,8 @@ struct State<Link, P: Paragraph> { paragraph: P, } -impl<Link, Theme, Renderer> Widget<Link, Theme, Renderer> - for Rich<'_, Link, Theme, Renderer> +impl<Link, Message, Theme, Renderer> Widget<Message, Theme, Renderer> + for Rich<'_, Link, Message, Theme, Renderer> where Link: Clone + 'static, Theme: Catalog, @@ -252,7 +271,8 @@ where let style = theme.style(&self.class); for (index, span) in self.spans.as_ref().as_ref().iter().enumerate() { - let is_hovered_link = Some(index) == self.hovered_link; + let is_hovered_link = self.on_link_clicked.is_some() + && Some(index) == self.hovered_link; if span.highlight.is_some() || span.underline @@ -363,9 +383,13 @@ where cursor: mouse::Cursor, _renderer: &Renderer, _clipboard: &mut dyn Clipboard, - shell: &mut Shell<'_, Link>, + shell: &mut Shell<'_, Message>, _viewport: &Rectangle, ) { + let Some(on_link_clicked) = &self.on_link_clicked else { + return; + }; + let was_hovered = self.hovered_link.is_some(); if let Some(position) = cursor.position_in(layout.bounds()) { @@ -414,7 +438,7 @@ where .get(span) .and_then(|span| span.link.clone()) { - shell.publish(link); + shell.publish(on_link_clicked(link)); } } _ => {} @@ -509,8 +533,9 @@ where }) } -impl<'a, Link, Theme, Renderer> FromIterator<Span<'a, Link, Renderer::Font>> - for Rich<'a, Link, Theme, Renderer> +impl<'a, Link, Message, Theme, Renderer> + FromIterator<Span<'a, Link, Renderer::Font>> + for Rich<'a, Link, Message, Theme, Renderer> where Link: Clone + 'a, Theme: Catalog, @@ -524,16 +549,18 @@ where } } -impl<'a, Link, Theme, Renderer> From<Rich<'a, Link, Theme, Renderer>> - for Element<'a, Link, Theme, Renderer> +impl<'a, Link, Message, Theme, Renderer> + From<Rich<'a, Link, Message, Theme, Renderer>> + for Element<'a, Message, Theme, Renderer> where + Message: 'a, Link: Clone + 'a, Theme: Catalog + 'a, Renderer: core::text::Renderer + 'a, { fn from( - text: Rich<'a, Link, Theme, Renderer>, - ) -> Element<'a, Link, Theme, Renderer> { + text: Rich<'a, Link, Message, Theme, Renderer>, + ) -> Element<'a, Message, Theme, Renderer> { Element::new(text) } } |