From e2c52c9728cb03d01b33388a8159db9bd9eac04c Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Mon, 27 Jan 2025 05:01:43 +0100 Subject: Implement AI `gallery` example :tada: It displays the most popular daily images of Civitai! --- examples/gallery/Cargo.toml | 23 +++ examples/gallery/src/civitai.rs | 148 +++++++++++++++++++ examples/gallery/src/main.rs | 308 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 479 insertions(+) create mode 100644 examples/gallery/Cargo.toml create mode 100644 examples/gallery/src/civitai.rs create mode 100644 examples/gallery/src/main.rs (limited to 'examples/gallery') diff --git a/examples/gallery/Cargo.toml b/examples/gallery/Cargo.toml new file mode 100644 index 00000000..573389b1 --- /dev/null +++ b/examples/gallery/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "gallery" +version = "0.1.0" +authors = ["Héctor Ramón Jiménez "] +edition = "2021" +publish = false + +[dependencies] +iced.workspace = true +iced.features = ["tokio", "image", "web-colors", "debug"] + +reqwest.version = "0.12" +reqwest.features = ["json"] + +serde.version = "1.0" +serde.features = ["derive"] + +bytes.workspace = true +image.workspace = true +tokio.workspace = true + +[lints] +workspace = true diff --git a/examples/gallery/src/civitai.rs b/examples/gallery/src/civitai.rs new file mode 100644 index 00000000..d1163013 --- /dev/null +++ b/examples/gallery/src/civitai.rs @@ -0,0 +1,148 @@ +use bytes::Bytes; +use serde::Deserialize; +use tokio::task; + +use std::fmt; +use std::io; +use std::sync::Arc; + +#[derive(Debug, Clone, Deserialize)] +pub struct Image { + pub id: Id, + url: String, +} + +impl Image { + pub async fn list() -> Result, Error> { + let client = reqwest::Client::new(); + + #[derive(Deserialize)] + struct Response { + items: Vec, + } + + let response: Response = client + .get(endpoint("/images")) + .query(&[ + ("sort", "Most Reactions"), + ("period", "Day"), + ("nsfw", "None"), + ("limit", "99"), + ]) + .send() + .await? + .error_for_status()? + .json() + .await?; + + Ok(response.items) + } + + pub async fn download(self, size: Size) -> Result { + let client = reqwest::Client::new(); + + let bytes = client + .get(match size { + Size::Original => self.url, + Size::Thumbnail => self + .url + .split("/") + .map(|part| { + if part.starts_with("width=") { + "width=640" + } else { + part + } + }) + .collect::>() + .join("/"), + }) + .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(Rgba { + width: image.width(), + height: image.height(), + pixels: Bytes::from(image.into_raw()), + }) + } +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, +)] +pub struct Id(u32); + +#[derive(Clone)] +pub struct Rgba { + pub width: u32, + pub height: u32, + pub pixels: Bytes, +} + +impl fmt::Debug for Rgba { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Rgba") + .field("width", &self.width) + .field("height", &self.height) + .finish() + } +} + +#[derive(Debug, Clone, Copy)] +pub enum Size { + Original, + Thumbnail, +} + +fn endpoint(path: &str) -> String { + const API_URL: &str = "https://civitai.com/api/v1"; + + format!("{API_URL}{path}") +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub enum Error { + RequestFailed(Arc), + IOFailed(Arc), + JoinFailed(Arc), + ImageDecodingFailed(Arc), +} + +impl From for Error { + fn from(error: reqwest::Error) -> Self { + Self::RequestFailed(Arc::new(error)) + } +} + +impl From for Error { + fn from(error: io::Error) -> Self { + Self::IOFailed(Arc::new(error)) + } +} + +impl From for Error { + fn from(error: task::JoinError) -> Self { + Self::JoinFailed(Arc::new(error)) + } +} + +impl From for Error { + fn from(error: image::ImageError) -> Self { + Self::ImageDecodingFailed(Arc::new(error)) + } +} diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs new file mode 100644 index 00000000..a7c107f7 --- /dev/null +++ b/examples/gallery/src/main.rs @@ -0,0 +1,308 @@ +//! A simple gallery that displays the daily featured images of Civitai. +//! +//! Showcases lazy loading of images in the background, as well as +//! some smooth animations. +mod civitai; + +use crate::civitai::{Error, Id, Image, Rgba, Size}; + +use iced::animation; +use iced::time::Instant; +use iced::widget::{ + button, center_x, container, horizontal_space, image, mouse_area, opaque, + pop, row, scrollable, stack, +}; +use iced::window; +use iced::{ + color, Animation, ContentFit, Element, Fill, Subscription, Task, Theme, +}; + +use std::collections::HashMap; + +fn main() -> iced::Result { + iced::application("Gallery - Iced", Gallery::update, Gallery::view) + .subscription(Gallery::subscription) + .theme(Gallery::theme) + .run_with(Gallery::new) +} + +struct Gallery { + images: Vec, + thumbnails: HashMap, + viewer: Viewer, + now: Instant, +} + +#[derive(Debug, Clone)] +enum Message { + ImagesListed(Result, Error>), + ImagePoppedIn(Id), + ImageDownloaded(Result), + ThumbnailDownloaded(Id, Result), + ThumbnailHovered(Id, bool), + Open(Id), + Close, + Animate(Instant), +} + +impl Gallery { + pub fn new() -> (Self, Task) { + ( + Self { + images: Vec::new(), + thumbnails: HashMap::new(), + viewer: Viewer::new(), + now: Instant::now(), + }, + Task::perform(Image::list(), Message::ImagesListed), + ) + } + + pub fn theme(&self) -> Theme { + Theme::TokyoNight + } + + pub fn subscription(&self) -> Subscription { + let is_animating = self + .thumbnails + .values() + .any(|thumbnail| thumbnail.is_animating(self.now)) + || self.viewer.is_animating(self.now); + + if is_animating { + window::frames().map(Message::Animate) + } else { + Subscription::none() + } + } + + pub fn update(&mut self, message: Message) -> Task { + match message { + Message::ImagesListed(Ok(images)) => { + self.images = images; + + Task::none() + } + Message::ImagePoppedIn(id) => { + let Some(image) = self + .images + .iter() + .find(|candidate| candidate.id == id) + .cloned() + else { + return Task::none(); + }; + + Task::perform(image.download(Size::Thumbnail), move |result| { + Message::ThumbnailDownloaded(id, result) + }) + } + Message::ImageDownloaded(Ok(rgba)) => { + self.viewer.show(rgba); + + Task::none() + } + Message::ThumbnailDownloaded(id, Ok(rgba)) => { + let thumbnail = Thumbnail { + handle: image::Handle::from_rgba( + rgba.width, + rgba.height, + rgba.pixels, + ), + fade_in: Animation::new(false).slow().go(true), + zoom: Animation::new(false) + .quick() + .easing(animation::Easing::EaseInOut), + }; + + let _ = self.thumbnails.insert(id, thumbnail); + + Task::none() + } + Message::ThumbnailHovered(id, is_hovered) => { + if let Some(thumbnail) = self.thumbnails.get_mut(&id) { + thumbnail.zoom.go_mut(is_hovered); + } + + Task::none() + } + Message::Open(id) => { + let Some(image) = self + .images + .iter() + .find(|candidate| candidate.id == id) + .cloned() + else { + return Task::none(); + }; + + self.viewer.open(); + + Task::perform( + image.download(Size::Original), + Message::ImageDownloaded, + ) + } + Message::Close => { + self.viewer.close(); + + Task::none() + } + Message::Animate(now) => { + self.now = now; + + Task::none() + } + Message::ImagesListed(Err(error)) + | Message::ImageDownloaded(Err(error)) + | Message::ThumbnailDownloaded(_, Err(error)) => { + dbg!(error); + + Task::none() + } + } + } + + pub fn view(&self) -> Element<'_, Message> { + let gallery = row(self.images.iter().map(|image| { + card(image, self.thumbnails.get(&image.id), self.now) + })) + .spacing(10) + .wrap(); + + let content = + container(scrollable(center_x(gallery)).spacing(10)).padding(10); + + let viewer = self.viewer.view(self.now); + + stack![content, viewer].into() + } +} + +struct Thumbnail { + handle: image::Handle, + fade_in: Animation, + zoom: Animation, +} + +impl Thumbnail { + fn is_animating(&self, now: Instant) -> bool { + self.fade_in.in_progress(now) || self.zoom.in_progress(now) + } +} + +fn card<'a>( + metadata: &'a Image, + thumbnail: Option<&'a Thumbnail>, + now: Instant, +) -> Element<'a, Message> { + let image: Element<'_, _> = if let Some(thumbnail) = thumbnail { + image(&thumbnail.handle) + .width(Fill) + .height(Fill) + .content_fit(ContentFit::Cover) + .opacity(thumbnail.fade_in.interpolate(0.0, 1.0, now)) + .scale(thumbnail.zoom.interpolate(1.0, 1.1, now)) + .into() + } else { + horizontal_space().into() + }; + + let card = mouse_area( + container(image) + .width(320) + .height(410) + .style(container::dark), + ) + .on_enter(Message::ThumbnailHovered(metadata.id, true)) + .on_exit(Message::ThumbnailHovered(metadata.id, false)); + + if thumbnail.is_some() { + button(card) + .on_press(Message::Open(metadata.id)) + .padding(0) + .style(button::text) + .into() + } else { + pop(card) + .on_show(Message::ImagePoppedIn(metadata.id)) + .into() + } +} + +struct Viewer { + image: Option, + background_fade_in: Animation, + image_fade_in: Animation, +} + +impl Viewer { + fn new() -> Self { + Self { + image: None, + background_fade_in: Animation::new(false) + .very_slow() + .easing(animation::Easing::EaseInOut), + image_fade_in: Animation::new(false) + .very_slow() + .easing(animation::Easing::EaseInOut), + } + } + + fn open(&mut self) { + self.image = None; + self.background_fade_in.go_mut(true); + } + + fn show(&mut self, rgba: Rgba) { + self.image = Some(image::Handle::from_rgba( + rgba.width, + rgba.height, + rgba.pixels, + )); + self.background_fade_in.go_mut(true); + self.image_fade_in.go_mut(true); + } + + fn close(&mut self) { + self.background_fade_in.go_mut(false); + self.image_fade_in.go_mut(false); + } + + fn is_animating(&self, now: Instant) -> bool { + self.background_fade_in.in_progress(now) + || self.image_fade_in.in_progress(now) + } + + fn view(&self, now: Instant) -> Element<'_, Message> { + let opacity = self.background_fade_in.interpolate(0.0, 0.8, now); + + let image: Element<'_, _> = if let Some(handle) = &self.image { + image(handle) + .width(Fill) + .height(Fill) + .opacity(self.image_fade_in.interpolate(0.0, 1.0, now)) + .scale(self.image_fade_in.interpolate(1.5, 1.0, now)) + .into() + } else { + horizontal_space().into() + }; + + if opacity > 0.0 { + opaque( + mouse_area( + container(image) + .center(Fill) + .style(move |_theme| { + container::Style::default() + .background(color!(0x000000, opacity)) + }) + .padding(20), + ) + .on_press(Message::Close), + ) + } else { + horizontal_space().into() + } + } +} -- cgit From e1ad0910f0a383372a481cab4545c4e20805b41c Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Mon, 27 Jan 2025 20:46:47 +0100 Subject: Change `period` to `Week` in `gallery` example --- examples/gallery/src/civitai.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'examples/gallery') diff --git a/examples/gallery/src/civitai.rs b/examples/gallery/src/civitai.rs index d1163013..5844fdfc 100644 --- a/examples/gallery/src/civitai.rs +++ b/examples/gallery/src/civitai.rs @@ -25,7 +25,7 @@ impl Image { .get(endpoint("/images")) .query(&[ ("sort", "Most Reactions"), - ("period", "Day"), + ("period", "Week"), ("nsfw", "None"), ("limit", "99"), ]) -- cgit From 086b06553b1b792577e9f663ae7721385696483e Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Mon, 27 Jan 2025 20:47:59 +0100 Subject: Remove unnecessary `endpoint` function in `gallery` example --- examples/gallery/src/civitai.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) (limited to 'examples/gallery') diff --git a/examples/gallery/src/civitai.rs b/examples/gallery/src/civitai.rs index 5844fdfc..7a8f1c45 100644 --- a/examples/gallery/src/civitai.rs +++ b/examples/gallery/src/civitai.rs @@ -22,7 +22,7 @@ impl Image { } let response: Response = client - .get(endpoint("/images")) + .get("https://civitai.com/api/v1/images") .query(&[ ("sort", "Most Reactions"), ("period", "Week"), @@ -108,12 +108,6 @@ pub enum Size { Thumbnail, } -fn endpoint(path: &str) -> String { - const API_URL: &str = "https://civitai.com/api/v1"; - - format!("{API_URL}{path}") -} - #[derive(Debug, Clone)] #[allow(dead_code)] pub enum Error { -- cgit From 23d42d2827a68468bc520440c6396eb114d793bc Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Tue, 28 Jan 2025 03:04:31 +0100 Subject: Rename `Animation::in_progress` to `is_animating` --- examples/gallery/src/main.rs | 55 +++++++++++++++++++++++--------------------- 1 file changed, 29 insertions(+), 26 deletions(-) (limited to 'examples/gallery') diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index a7c107f7..7b3b6c35 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -103,18 +103,7 @@ impl Gallery { Task::none() } Message::ThumbnailDownloaded(id, Ok(rgba)) => { - let thumbnail = Thumbnail { - handle: image::Handle::from_rgba( - rgba.width, - rgba.height, - rgba.pixels, - ), - fade_in: Animation::new(false).slow().go(true), - zoom: Animation::new(false) - .quick() - .easing(animation::Easing::EaseInOut), - }; - + let thumbnail = Thumbnail::new(rgba); let _ = self.thumbnails.insert(id, thumbnail); Task::none() @@ -179,18 +168,6 @@ impl Gallery { } } -struct Thumbnail { - handle: image::Handle, - fade_in: Animation, - zoom: Animation, -} - -impl Thumbnail { - fn is_animating(&self, now: Instant) -> bool { - self.fade_in.in_progress(now) || self.zoom.in_progress(now) - } -} - fn card<'a>( metadata: &'a Image, thumbnail: Option<&'a Thumbnail>, @@ -230,6 +207,32 @@ fn card<'a>( } } +struct Thumbnail { + handle: image::Handle, + fade_in: Animation, + zoom: Animation, +} + +impl Thumbnail { + fn new(rgba: Rgba) -> Self { + Self { + handle: image::Handle::from_rgba( + rgba.width, + rgba.height, + rgba.pixels, + ), + fade_in: Animation::new(false).slow().go(true), + zoom: Animation::new(false) + .quick() + .easing(animation::Easing::EaseInOut), + } + } + + fn is_animating(&self, now: Instant) -> bool { + self.fade_in.is_animating(now) || self.zoom.is_animating(now) + } +} + struct Viewer { image: Option, background_fade_in: Animation, @@ -270,8 +273,8 @@ impl Viewer { } fn is_animating(&self, now: Instant) -> bool { - self.background_fade_in.in_progress(now) - || self.image_fade_in.in_progress(now) + self.background_fade_in.is_animating(now) + || self.image_fade_in.is_animating(now) } fn view(&self, now: Instant) -> Element<'_, Message> { -- cgit From cd445f758f08f97a719a3320d54df30620aa4434 Mon Sep 17 00:00:00 2001 From: Héctor Ramón Jiménez Date: Tue, 28 Jan 2025 03:25:18 +0100 Subject: Display grid placeholder when loading `gallery` example --- examples/gallery/src/civitai.rs | 4 +++- examples/gallery/src/main.rs | 25 ++++++++++++++++++++----- 2 files changed, 23 insertions(+), 6 deletions(-) (limited to 'examples/gallery') diff --git a/examples/gallery/src/civitai.rs b/examples/gallery/src/civitai.rs index 7a8f1c45..986b6bf2 100644 --- a/examples/gallery/src/civitai.rs +++ b/examples/gallery/src/civitai.rs @@ -13,6 +13,8 @@ pub struct Image { } impl Image { + pub const LIMIT: usize = 99; + pub async fn list() -> Result, Error> { let client = reqwest::Client::new(); @@ -27,7 +29,7 @@ impl Image { ("sort", "Most Reactions"), ("period", "Week"), ("nsfw", "None"), - ("limit", "99"), + ("limit", &Image::LIMIT.to_string()), ]) .send() .await? diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 7b3b6c35..9092cf9e 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -153,9 +153,13 @@ impl Gallery { } pub fn view(&self) -> Element<'_, Message> { - let gallery = row(self.images.iter().map(|image| { - card(image, self.thumbnails.get(&image.id), self.now) - })) + let gallery = if self.images.is_empty() { + row((0..=Image::LIMIT).map(|_| placeholder())) + } else { + row(self.images.iter().map(|image| { + card(image, self.thumbnails.get(&image.id), self.now) + })) + } .spacing(10) .wrap(); @@ -187,8 +191,8 @@ fn card<'a>( let card = mouse_area( container(image) - .width(320) - .height(410) + .width(Thumbnail::WIDTH) + .height(Thumbnail::HEIGHT) .style(container::dark), ) .on_enter(Message::ThumbnailHovered(metadata.id, true)) @@ -207,6 +211,14 @@ fn card<'a>( } } +fn placeholder<'a>() -> Element<'a, Message> { + container(horizontal_space()) + .width(Thumbnail::WIDTH) + .height(Thumbnail::HEIGHT) + .style(container::dark) + .into() +} + struct Thumbnail { handle: image::Handle, fade_in: Animation, @@ -214,6 +226,9 @@ struct Thumbnail { } impl Thumbnail { + const WIDTH: u16 = 320; + const HEIGHT: u16 = 410; + fn new(rgba: Rgba) -> Self { Self { handle: image::Handle::from_rgba( -- cgit