Animate image fade in in markdown example

This commit is contained in:
Héctor Ramón Jiménez 2025-02-04 19:57:51 +01:00
parent 24cf355e96
commit a6e64eac6f
No known key found for this signature in database
GPG key ID: 7CC46565708259A7
2 changed files with 104 additions and 51 deletions

View file

@ -1,12 +1,13 @@
use iced::animation;
use iced::highlighter; use iced::highlighter;
use iced::time::{self, milliseconds}; use iced::task;
use iced::time::{self, milliseconds, Instant};
use iced::widget::{ use iced::widget::{
self, center_x, horizontal_space, hover, image, markdown, pop, right, row, self, center_x, horizontal_space, hover, image, markdown, pop, right, row,
scrollable, text_editor, toggler, 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 tokio::task;
use std::collections::HashMap; use std::collections::HashMap;
use std::io; use std::io;
@ -20,23 +21,27 @@ pub fn main() -> iced::Result {
} }
struct Markdown { struct Markdown {
content: text_editor::Content, content: markdown::Content,
raw: text_editor::Content,
images: HashMap<markdown::Url, Image>, images: HashMap<markdown::Url, Image>,
mode: Mode, mode: Mode,
theme: Theme, theme: Theme,
now: Instant,
} }
enum Mode { enum Mode {
Preview(Vec<markdown::Item>), Preview,
Stream { Stream { pending: String },
pending: String,
parsed: markdown::Content,
},
} }
enum Image { enum Image {
Loading, Loading {
Ready(image::Handle), _download: task::Handle,
},
Ready {
handle: image::Handle,
fade_in: Animation<bool>,
},
#[allow(dead_code)] #[allow(dead_code)]
Errored(Error), Errored(Error),
} }
@ -49,20 +54,21 @@ enum Message {
ImageDownloaded(markdown::Url, Result<image::Handle, Error>), ImageDownloaded(markdown::Url, Result<image::Handle, Error>),
ToggleStream(bool), ToggleStream(bool),
NextToken, NextToken,
Animate(Instant),
} }
impl Markdown { impl Markdown {
fn new() -> (Self, Task<Message>) { fn new() -> (Self, Task<Message>) {
const INITIAL_CONTENT: &str = include_str!("../overview.md"); const INITIAL_CONTENT: &str = include_str!("../overview.md");
let theme = Theme::TokyoNight;
( (
Self { Self {
content: text_editor::Content::with_text(INITIAL_CONTENT), content: markdown::Content::parse(INITIAL_CONTENT),
raw: text_editor::Content::with_text(INITIAL_CONTENT),
images: HashMap::new(), images: HashMap::new(),
mode: Mode::Preview(markdown::parse(INITIAL_CONTENT).collect()), mode: Mode::Preview,
theme, theme: Theme::TokyoNight,
now: Instant::now(),
}, },
widget::focus_next(), widget::focus_next(),
) )
@ -73,12 +79,14 @@ impl Markdown {
Message::Edit(action) => { Message::Edit(action) => {
let is_edit = action.is_edit(); let is_edit = action.is_edit();
self.content.perform(action); self.raw.perform(action);
if is_edit { if is_edit {
self.mode = Mode::Preview( self.content = markdown::Content::parse(&self.raw.text());
markdown::parse(&self.content.text()).collect(), self.mode = Mode::Preview;
);
let images = self.content.images();
self.images.retain(|url, _image| images.contains(url));
} }
Task::none() Task::none()
@ -93,16 +101,40 @@ impl Markdown {
return Task::none(); return Task::none();
} }
let _ = self.images.insert(url.clone(), Image::Loading); let (download_image, handle) = Task::future({
let url = url.clone();
Task::perform(download_image(url.clone()), move |result| { 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.clone(), result)
}) })
} }
Message::ImageDownloaded(url, result) => { Message::ImageDownloaded(url, result) => {
let _ = self.images.insert( let _ = self.images.insert(
url, url,
result.map(Image::Ready).unwrap_or_else(Image::Errored), 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() Task::none()
@ -110,8 +142,7 @@ impl Markdown {
Message::ToggleStream(enable_stream) => { Message::ToggleStream(enable_stream) => {
if enable_stream { if enable_stream {
self.mode = Mode::Stream { self.mode = Mode::Stream {
pending: self.content.text(), pending: self.raw.text(),
parsed: markdown::Content::new(),
}; };
scrollable::snap_to( scrollable::snap_to(
@ -119,24 +150,22 @@ impl Markdown {
scrollable::RelativeOffset::END, scrollable::RelativeOffset::END,
) )
} else { } else {
self.mode = Mode::Preview( self.mode = Mode::Preview;
markdown::parse(&self.content.text()).collect(),
);
Task::none() Task::none()
} }
} }
Message::NextToken => { Message::NextToken => {
match &mut self.mode { match &mut self.mode {
Mode::Preview(_) => {} Mode::Preview => {}
Mode::Stream { pending, parsed } => { Mode::Stream { pending } => {
if pending.is_empty() { if pending.is_empty() {
self.mode = Mode::Preview(parsed.items().to_vec()); self.mode = Mode::Preview;
} else { } else {
let mut tokens = pending.split(' '); let mut tokens = pending.split(' ');
if let Some(token) = tokens.next() { if let Some(token) = tokens.next() {
parsed.push_str(&format!("{token} ")); self.content.push_str(&format!("{token} "));
} }
*pending = tokens.collect::<Vec<_>>().join(" "); *pending = tokens.collect::<Vec<_>>().join(" ");
@ -144,13 +173,18 @@ impl Markdown {
} }
} }
Task::none()
}
Message::Animate(now) => {
self.now = now;
Task::none() Task::none()
} }
} }
} }
fn view(&self) -> Element<Message> { fn view(&self) -> Element<Message> {
let editor = text_editor(&self.content) let editor = text_editor(&self.raw)
.placeholder("Type your Markdown here...") .placeholder("Type your Markdown here...")
.on_action(Message::Edit) .on_action(Message::Edit)
.height(Fill) .height(Fill)
@ -158,16 +192,12 @@ impl Markdown {
.font(Font::MONOSPACE) .font(Font::MONOSPACE)
.highlight("markdown", highlighter::Theme::Base16Ocean); .highlight("markdown", highlighter::Theme::Base16Ocean);
let items = match &self.mode {
Mode::Preview(items) => items.as_slice(),
Mode::Stream { parsed, .. } => parsed.items(),
};
let preview = markdown::view_with( let preview = markdown::view_with(
items, self.content.items(),
&self.theme, &self.theme,
&MarkdownViewer { &MarkdownViewer {
images: &self.images, images: &self.images,
now: self.now,
}, },
); );
@ -197,17 +227,33 @@ impl Markdown {
} }
fn subscription(&self) -> Subscription<Message> { fn subscription(&self) -> Subscription<Message> {
match self.mode { let listen_stream = match self.mode {
Mode::Preview(_) => Subscription::none(), Mode::Preview => Subscription::none(),
Mode::Stream { .. } => { Mode::Stream { .. } => {
time::every(milliseconds(10)).map(|_| Message::NextToken) 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 MarkdownViewer<'a> { struct MarkdownViewer<'a> {
images: &'a HashMap<markdown::Url, Image>, images: &'a HashMap<markdown::Url, Image>,
now: Instant,
} }
impl<'a> markdown::Viewer<'a, Message> for MarkdownViewer<'a> { impl<'a> markdown::Viewer<'a, Message> for MarkdownViewer<'a> {
@ -221,10 +267,15 @@ impl<'a> markdown::Viewer<'a, Message> for MarkdownViewer<'a> {
_title: &markdown::Text, _title: &markdown::Text,
url: &'a markdown::Url, url: &'a markdown::Url,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
if let Some(Image::Ready(handle)) = self.images.get(url) { if let Some(Image::Ready { handle, fade_in }) = self.images.get(url) {
center_x(image(handle)).into() 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 { } else {
pop(horizontal_space().width(0)) pop(horizontal_space())
.key(url.as_str()) .key(url.as_str())
.on_show(|_size| Message::ImageShown(url.clone())) .on_show(|_size| Message::ImageShown(url.clone()))
.into() .into()
@ -236,6 +287,8 @@ async fn download_image(url: markdown::Url) -> Result<image::Handle, Error> {
use std::io; use std::io;
use tokio::task; use tokio::task;
println!("Trying to download image: {url}");
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let bytes = client let bytes = client
@ -267,7 +320,7 @@ async fn download_image(url: markdown::Url) -> Result<image::Handle, Error> {
pub enum Error { pub enum Error {
RequestFailed(Arc<reqwest::Error>), RequestFailed(Arc<reqwest::Error>),
IOFailed(Arc<io::Error>), IOFailed(Arc<io::Error>),
JoinFailed(Arc<task::JoinError>), JoinFailed(Arc<tokio::task::JoinError>),
ImageDecodingFailed(Arc<::image::ImageError>), ImageDecodingFailed(Arc<::image::ImageError>),
} }
@ -283,8 +336,8 @@ impl From<io::Error> for Error {
} }
} }
impl From<task::JoinError> for Error { impl From<tokio::task::JoinError> for Error {
fn from(error: task::JoinError) -> Self { fn from(error: tokio::task::JoinError) -> Self {
Self::JoinFailed(Arc::new(error)) Self::JoinFailed(Arc::new(error))
} }
} }

View file

@ -167,8 +167,8 @@ impl Content {
} }
/// Returns the URLs of the Markdown images present in the [`Content`]. /// Returns the URLs of the Markdown images present in the [`Content`].
pub fn images(&self) -> impl Iterator<Item = &Url> { pub fn images(&self) -> &HashSet<Url> {
self.state.images.iter() &self.state.images
} }
} }