diff options
author | 2025-02-05 02:20:36 +0100 | |
---|---|---|
committer | 2025-02-05 02:20:36 +0100 | |
commit | 4bbb5cbc1f8b2a0ee8e09be18071368df3ba5bbd (patch) | |
tree | 076d5bcebcc1686c069d79375357ef013de16cc3 /examples | |
parent | 1f9723a9296082ea06b7280833c60e8f2e547cb5 (diff) | |
parent | ef25dfb7331c7ab5446e9b771c9bca8aff231957 (diff) | |
download | iced-4bbb5cbc1f8b2a0ee8e09be18071368df3ba5bbd.tar.gz iced-4bbb5cbc1f8b2a0ee8e09be18071368df3ba5bbd.tar.bz2 iced-4bbb5cbc1f8b2a0ee8e09be18071368df3ba5bbd.zip |
Merge pull request #2786 from iced-rs/customizable-markdown
Customizable Markdown Rendering and Image Support
Diffstat (limited to 'examples')
-rw-r--r-- | examples/changelog/src/main.rs | 26 | ||||
-rw-r--r-- | examples/markdown/Cargo.toml | 13 | ||||
-rw-r--r-- | examples/markdown/build.rs | 5 | ||||
-rw-r--r-- | examples/markdown/fonts/markdown-icons.toml | 4 | ||||
-rw-r--r-- | examples/markdown/fonts/markdown-icons.ttf | bin | 0 -> 5856 bytes | |||
-rw-r--r-- | examples/markdown/src/icon.rs | 15 | ||||
-rw-r--r-- | examples/markdown/src/main.rs | 291 |
7 files changed, 298 insertions, 56 deletions
diff --git a/examples/changelog/src/main.rs b/examples/changelog/src/main.rs index f889e757..a1d0d799 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 fa6ced74..7af1741b 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 00000000..ecbb7666 --- /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 00000000..60c91d17 --- /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 Binary files differnew file mode 100644 index 00000000..013f03a5 --- /dev/null +++ b/examples/markdown/fonts/markdown-icons.ttf diff --git a/examples/markdown/src/icon.rs b/examples/markdown/src/icon.rs new file mode 100644 index 00000000..cfe32541 --- /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 ba93ee18..512d4b44 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<markdown::Url, Image>, mode: Mode, theme: Theme, + now: Instant, } enum Mode { - Preview(Vec<markdown::Item>), - Stream { - pending: String, - parsed: markdown::Content, + Preview, + Stream { pending: String }, +} + +enum Image { + Loading { + _download: task::Handle, + }, + Ready { + handle: image::Handle, + fade_in: Animation<bool>, }, + #[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<image::Handle, Error>), ToggleStream(bool), NextToken, + Animate(Instant), } impl Markdown { fn new() -> (Self, Task<Message>) { 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::<Vec<_>>().join(" "); @@ -109,11 +183,16 @@ impl Markdown { Task::none() } + Message::Animate(now) => { + self.now = now; + + Task::none() + } } } fn view(&self) -> Element<Message> { - 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<Message> { - 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<markdown::Url, Image>, + 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<image::Handle, Error> { + 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<reqwest::Error>), + IOFailed(Arc<io::Error>), + JoinFailed(Arc<tokio::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<tokio::task::JoinError> 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)) + } } |