diff options
Diffstat (limited to 'examples/markdown/src/main.rs')
-rw-r--r-- | examples/markdown/src/main.rs | 291 |
1 files changed, 251 insertions, 40 deletions
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)) + } } |