summaryrefslogtreecommitdiffstats
path: root/examples
diff options
context:
space:
mode:
authorLibravatar Héctor Ramón Jiménez <hector@hecrj.dev>2025-01-27 05:01:43 +0100
committerLibravatar Héctor Ramón Jiménez <hector@hecrj.dev>2025-01-27 05:01:43 +0100
commite2c52c9728cb03d01b33388a8159db9bd9eac04c (patch)
treeb228e5b08e614887c5c70546427487eb2266358c /examples
parent890d852e0544ffbf06ac7eb8c3904dc04a0008cf (diff)
downloadiced-e2c52c9728cb03d01b33388a8159db9bd9eac04c.tar.gz
iced-e2c52c9728cb03d01b33388a8159db9bd9eac04c.tar.bz2
iced-e2c52c9728cb03d01b33388a8159db9bd9eac04c.zip
Implement AI `gallery` example :tada:
It displays the most popular daily images of Civitai!
Diffstat (limited to 'examples')
-rw-r--r--examples/changelog/Cargo.toml3
-rw-r--r--examples/download_progress/Cargo.toml3
-rw-r--r--examples/gallery/Cargo.toml23
-rw-r--r--examples/gallery/src/civitai.rs148
-rw-r--r--examples/gallery/src/main.rs308
-rw-r--r--examples/pokedex/Cargo.toml3
6 files changed, 482 insertions, 6 deletions
diff --git a/examples/changelog/Cargo.toml b/examples/changelog/Cargo.toml
index eeb7b526..6e314947 100644
--- a/examples/changelog/Cargo.toml
+++ b/examples/changelog/Cargo.toml
@@ -23,5 +23,4 @@ tracing-subscriber = "0.3"
[dependencies.reqwest]
version = "0.12"
-default-features = false
-features = ["json", "rustls-tls"]
+features = ["json"]
diff --git a/examples/download_progress/Cargo.toml b/examples/download_progress/Cargo.toml
index 61a1b257..8632c8b8 100644
--- a/examples/download_progress/Cargo.toml
+++ b/examples/download_progress/Cargo.toml
@@ -11,5 +11,4 @@ iced.features = ["tokio"]
[dependencies.reqwest]
version = "0.12"
-default-features = false
-features = ["stream", "rustls-tls"]
+features = ["stream"]
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 <hector0193@gmail.com>"]
+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<Vec<Self>, Error> {
+ let client = reqwest::Client::new();
+
+ #[derive(Deserialize)]
+ struct Response {
+ items: Vec<Image>,
+ }
+
+ 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<Rgba, Error> {
+ 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::<Vec<_>>()
+ .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<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/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<Image>,
+ thumbnails: HashMap<Id, Thumbnail>,
+ viewer: Viewer,
+ now: Instant,
+}
+
+#[derive(Debug, Clone)]
+enum Message {
+ ImagesListed(Result<Vec<Image>, Error>),
+ ImagePoppedIn(Id),
+ ImageDownloaded(Result<Rgba, Error>),
+ ThumbnailDownloaded(Id, Result<Rgba, Error>),
+ ThumbnailHovered(Id, bool),
+ Open(Id),
+ Close,
+ Animate(Instant),
+}
+
+impl Gallery {
+ pub fn new() -> (Self, Task<Message>) {
+ (
+ 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<Message> {
+ 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<Message> {
+ 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<bool>,
+ zoom: Animation<bool>,
+}
+
+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<image::Handle>,
+ background_fade_in: Animation<bool>,
+ image_fade_in: Animation<bool>,
+}
+
+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()
+ }
+ }
+}
diff --git a/examples/pokedex/Cargo.toml b/examples/pokedex/Cargo.toml
index 1a6d5445..b3be4e14 100644
--- a/examples/pokedex/Cargo.toml
+++ b/examples/pokedex/Cargo.toml
@@ -17,8 +17,7 @@ features = ["derive"]
[dependencies.reqwest]
version = "0.12"
-default-features = false
-features = ["json", "rustls-tls"]
+features = ["json"]
[dependencies.rand]
version = "0.8"