From ab236376a3f8f1ac3cdd8aeb0ffeee45e3de37e3 Mon Sep 17 00:00:00 2001 From: Cory Forsstrom Date: Thu, 6 Feb 2025 13:54:45 -0800 Subject: [PATCH 1/6] Add blurhash to gallery --- Cargo.lock | 7 ++ examples/gallery/Cargo.toml | 2 + examples/gallery/src/civitai.rs | 33 +++++- examples/gallery/src/main.rs | 187 ++++++++++++++++++++++++++------ 4 files changed, 191 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e14e0ff70..c5e9443f4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -664,6 +664,12 @@ dependencies = [ "piper", ] +[[package]] +name = "blurhash" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79769241dcd44edf79a732545e8b5cec84c247ac060f5252cd51885d093a8fc" + [[package]] name = "built" version = "0.7.5" @@ -1863,6 +1869,7 @@ dependencies = [ name = "gallery" version = "0.1.0" dependencies = [ + "blurhash", "bytes", "iced", "image", diff --git a/examples/gallery/Cargo.toml b/examples/gallery/Cargo.toml index 573389b138..c9dc1e9d44 100644 --- a/examples/gallery/Cargo.toml +++ b/examples/gallery/Cargo.toml @@ -19,5 +19,7 @@ bytes.workspace = true image.workspace = true tokio.workspace = true +blurhash = "0.2.3" + [lints] workspace = true diff --git a/examples/gallery/src/civitai.rs b/examples/gallery/src/civitai.rs index 986b6bf2cf..c394ef1dbe 100644 --- a/examples/gallery/src/civitai.rs +++ b/examples/gallery/src/civitai.rs @@ -10,6 +10,7 @@ use std::sync::Arc; pub struct Image { pub id: Id, url: String, + hash: String, } impl Image { @@ -40,20 +41,37 @@ impl Image { Ok(response.items) } + pub async fn blurhash( + self, + width: u32, + height: u32, + ) -> Result { + task::spawn_blocking(move || { + let pixels = blurhash::decode(&self.hash, width, height, 1.0)?; + + Ok::<_, Error>(Rgba { + width, + height, + pixels: Bytes::from(pixels), + }) + }) + .await? + } + 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 + Size::Thumbnail { width } => self .url .split("/") .map(|part| { if part.starts_with("width=") { - "width=640" + format!("width={width}") } else { - part + part.to_string() } }) .collect::>() @@ -107,7 +125,7 @@ impl fmt::Debug for Rgba { #[derive(Debug, Clone, Copy)] pub enum Size { Original, - Thumbnail, + Thumbnail { width: u16 }, } #[derive(Debug, Clone)] @@ -117,6 +135,7 @@ pub enum Error { IOFailed(Arc), JoinFailed(Arc), ImageDecodingFailed(Arc), + BlurhashDecodingFailed(Arc), } impl From for Error { @@ -142,3 +161,9 @@ impl From for Error { Self::ImageDecodingFailed(Arc::new(error)) } } + +impl From for Error { + fn from(error: blurhash::Error) -> Self { + Self::BlurhashDecodingFailed(Arc::new(error)) + } +} diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 290fa6a07c..6175f39659 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -18,6 +18,7 @@ use iced::{ }; use std::collections::HashMap; +use std::time::Duration; fn main() -> iced::Result { iced::application("Gallery - Iced", Gallery::update, Gallery::view) @@ -28,7 +29,7 @@ fn main() -> iced::Result { struct Gallery { images: Vec, - thumbnails: HashMap, + previews: HashMap, viewer: Viewer, now: Instant, } @@ -40,6 +41,7 @@ enum Message { ImageDownloaded(Result), ThumbnailDownloaded(Id, Result), ThumbnailHovered(Id, bool), + BlurhashDecoded(Id, Result), Open(Id), Close, Animate(Instant), @@ -50,7 +52,7 @@ impl Gallery { ( Self { images: Vec::new(), - thumbnails: HashMap::new(), + previews: HashMap::new(), viewer: Viewer::new(), now: Instant::now(), }, @@ -64,7 +66,7 @@ impl Gallery { pub fn subscription(&self) -> Subscription { let is_animating = self - .thumbnails + .previews .values() .any(|thumbnail| thumbnail.is_animating(self.now)) || self.viewer.is_animating(self.now); @@ -93,9 +95,21 @@ impl Gallery { return Task::none(); }; - Task::perform(image.download(Size::Thumbnail), move |result| { - Message::ThumbnailDownloaded(id, result) - }) + Task::batch(vec![ + Task::perform( + image.clone().blurhash( + Preview::WIDTH as u32, + Preview::HEIGHT as u32, + ), + move |result| Message::BlurhashDecoded(id, result), + ), + Task::perform( + image.download(Size::Thumbnail { + width: Preview::WIDTH, + }), + move |result| Message::ThumbnailDownloaded(id, result), + ), + ]) } Message::ImageDownloaded(Ok(rgba)) => { self.viewer.show(rgba); @@ -103,18 +117,31 @@ impl Gallery { Task::none() } Message::ThumbnailDownloaded(id, Ok(rgba)) => { - let thumbnail = Thumbnail::new(rgba); - let _ = self.thumbnails.insert(id, thumbnail); + let blurhash = match self.previews.remove(&id) { + Some(Preview::Blurhash(blurhash)) => Some(blurhash), + _ => None, + }; + + let _ = self + .previews + .insert(id, Preview::thumbnail(self.now, blurhash, rgba)); Task::none() } Message::ThumbnailHovered(id, is_hovered) => { - if let Some(thumbnail) = self.thumbnails.get_mut(&id) { - thumbnail.zoom.go_mut(is_hovered); + if let Some(Preview::Thumbnail { zoom, .. }) = + self.previews.get_mut(&id) + { + zoom.go_mut(is_hovered); } Task::none() } + Message::BlurhashDecoded(id, Ok(rgba)) => { + let _ = self.previews.insert(id, Preview::blurhash(rgba)); + + Task::none() + } Message::Open(id) => { let Some(image) = self .images @@ -144,7 +171,8 @@ impl Gallery { } Message::ImagesListed(Err(error)) | Message::ImageDownloaded(Err(error)) - | Message::ThumbnailDownloaded(_, Err(error)) => { + | Message::ThumbnailDownloaded(_, Err(error)) + | Message::BlurhashDecoded(_, Err(error)) => { dbg!(error); Task::none() @@ -157,7 +185,7 @@ impl Gallery { row((0..=Image::LIMIT).map(|_| placeholder())) } else { row(self.images.iter().map(|image| { - card(image, self.thumbnails.get(&image.id), self.now) + card(image, self.previews.get(&image.id), self.now) })) } .spacing(10) @@ -174,33 +202,78 @@ impl Gallery { fn card<'a>( metadata: &'a Image, - thumbnail: Option<&'a Thumbnail>, + preview: Option<&'a Preview>, now: Instant, ) -> Element<'a, Message> { - let image: Element<'_, _> = if let Some(thumbnail) = thumbnail { - image(&thumbnail.handle) + let image: Element<'_, _> = match preview { + Some(Preview::Blurhash(Blurhash { handle, fade_in })) => image(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() + .opacity(fade_in.interpolate(0.0, Blurhash::MAX_OPACITY, now)) + .into(), + // Blurhash still needs to fade all the way in + Some(Preview::Thumbnail { + blurhash: Some(blurhash), + .. + }) if blurhash.fade_in.is_animating(now) => image(&blurhash.handle) + .width(Fill) + .height(Fill) + .content_fit(ContentFit::Cover) + .opacity(blurhash.fade_in.interpolate( + 0.0, + Blurhash::MAX_OPACITY, + now, + )) + .into(), + Some(Preview::Thumbnail { + blurhash, + thumbnail, + fade_in, + zoom, + }) => stack![] + // Transition between blurhash & thumbnail over the fade-in period + .push_maybe( + blurhash.as_ref().filter(|_| fade_in.is_animating(now)).map( + |blurhash| { + image(&blurhash.handle) + .width(Fill) + .height(Fill) + .content_fit(ContentFit::Cover) + .opacity(fade_in.interpolate( + Blurhash::MAX_OPACITY, + 0.0, + now, + )) + }, + ), + ) + .push( + image(thumbnail) + .width(Fill) + .height(Fill) + .content_fit(ContentFit::Cover) + .opacity(fade_in.interpolate(0.0, 1.0, now)) + .scale(zoom.interpolate(1.0, 1.1, now)), + ) + .into(), + None => horizontal_space().into(), }; let card = mouse_area( container(image) - .width(Thumbnail::WIDTH) - .height(Thumbnail::HEIGHT) + .width(Preview::WIDTH) + .height(Preview::HEIGHT) .style(container::dark), ) .on_enter(Message::ThumbnailHovered(metadata.id, true)) .on_exit(Message::ThumbnailHovered(metadata.id, false)); - if thumbnail.is_some() { + if let Some(preview) = preview { + let is_thumbnail = matches!(preview, Preview::Thumbnail { .. }); + button(card) - .on_press(Message::Open(metadata.id)) + .on_press_maybe(is_thumbnail.then_some(Message::Open(metadata.id))) .padding(0) .style(button::text) .into() @@ -213,30 +286,69 @@ fn card<'a>( fn placeholder<'a>() -> Element<'a, Message> { container(horizontal_space()) - .width(Thumbnail::WIDTH) - .height(Thumbnail::HEIGHT) + .width(Preview::WIDTH) + .height(Preview::HEIGHT) .style(container::dark) .into() } -struct Thumbnail { - handle: image::Handle, +struct Blurhash { fade_in: Animation, - zoom: Animation, + handle: image::Handle, } -impl Thumbnail { +impl Blurhash { + const FADE_IN: Duration = Duration::from_millis(200); + const MAX_OPACITY: f32 = 0.6; +} + +enum Preview { + Blurhash(Blurhash), + Thumbnail { + blurhash: Option, + thumbnail: image::Handle, + fade_in: Animation, + zoom: Animation, + }, +} + +impl Preview { const WIDTH: u16 = 320; const HEIGHT: u16 = 410; - fn new(rgba: Rgba) -> Self { - Self { + fn blurhash(rgba: Rgba) -> Self { + Self::Blurhash(Blurhash { + fade_in: Animation::new(false).duration(Blurhash::FADE_IN).go(true), handle: image::Handle::from_rgba( rgba.width, rgba.height, rgba.pixels, ), - fade_in: Animation::new(false).slow().go(true), + }) + } + + fn thumbnail(now: Instant, blurhash: Option, rgba: Rgba) -> Self { + // Delay the thumbnail fade in until blurhash is fully + // faded in itself + let delay = blurhash + .as_ref() + .map(|blurhash| { + Duration::from_secs_f32(blurhash.fade_in.interpolate( + 0.0, + Blurhash::FADE_IN.as_secs_f32(), + now, + )) + }) + .unwrap_or_default(); + + Self::Thumbnail { + blurhash, + thumbnail: image::Handle::from_rgba( + rgba.width, + rgba.height, + rgba.pixels, + ), + fade_in: Animation::new(false).very_slow().delay(delay).go(true), zoom: Animation::new(false) .quick() .easing(animation::Easing::EaseInOut), @@ -244,7 +356,14 @@ impl Thumbnail { } fn is_animating(&self, now: Instant) -> bool { - self.fade_in.is_animating(now) || self.zoom.is_animating(now) + match self { + Preview::Blurhash(Blurhash { fade_in, .. }) => { + fade_in.is_animating(now) + } + Preview::Thumbnail { fade_in, zoom, .. } => { + fade_in.is_animating(now) || zoom.is_animating(now) + } + } } } From f3ae4266e9727940ba0d0e8469362923590916f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sun, 9 Feb 2025 06:38:48 +0100 Subject: [PATCH 2/6] Implement `From` instead of `u16` for `Length` and `Pixels` --- core/src/length.rs | 6 +++--- core/src/pixels.rs | 6 +++--- examples/gallery/src/civitai.rs | 2 +- examples/gallery/src/main.rs | 9 +++------ examples/scrollable/src/main.rs | 12 ++++++------ examples/tour/src/main.rs | 14 +++++++------- 6 files changed, 23 insertions(+), 26 deletions(-) diff --git a/core/src/length.rs b/core/src/length.rs index 5f24169f1d..363833c426 100644 --- a/core/src/length.rs +++ b/core/src/length.rs @@ -77,8 +77,8 @@ impl From for Length { } } -impl From for Length { - fn from(units: u16) -> Self { - Length::Fixed(f32::from(units)) +impl From for Length { + fn from(units: u32) -> Self { + Length::Fixed(units as f32) } } diff --git a/core/src/pixels.rs b/core/src/pixels.rs index 7d6267cfea..c87e2b319d 100644 --- a/core/src/pixels.rs +++ b/core/src/pixels.rs @@ -20,9 +20,9 @@ impl From for Pixels { } } -impl From for Pixels { - fn from(amount: u16) -> Self { - Self(f32::from(amount)) +impl From for Pixels { + fn from(amount: u32) -> Self { + Self(amount as f32) } } diff --git a/examples/gallery/src/civitai.rs b/examples/gallery/src/civitai.rs index c394ef1dbe..457091e9cb 100644 --- a/examples/gallery/src/civitai.rs +++ b/examples/gallery/src/civitai.rs @@ -125,7 +125,7 @@ impl fmt::Debug for Rgba { #[derive(Debug, Clone, Copy)] pub enum Size { Original, - Thumbnail { width: u16 }, + Thumbnail { width: u32 }, } #[derive(Debug, Clone)] diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 6175f39659..3bc663826a 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -97,10 +97,7 @@ impl Gallery { Task::batch(vec![ Task::perform( - image.clone().blurhash( - Preview::WIDTH as u32, - Preview::HEIGHT as u32, - ), + image.clone().blurhash(Preview::WIDTH, Preview::HEIGHT), move |result| Message::BlurhashDecoded(id, result), ), Task::perform( @@ -313,8 +310,8 @@ enum Preview { } impl Preview { - const WIDTH: u16 = 320; - const HEIGHT: u16 = 410; + const WIDTH: u32 = 320; + const HEIGHT: u32 = 410; fn blurhash(rgba: Rgba) -> Self { Self::Blurhash(Blurhash { diff --git a/examples/scrollable/src/main.rs b/examples/scrollable/src/main.rs index 6359fb5a19..fec4e1b487 100644 --- a/examples/scrollable/src/main.rs +++ b/examples/scrollable/src/main.rs @@ -21,9 +21,9 @@ pub fn main() -> iced::Result { struct ScrollableDemo { scrollable_direction: Direction, - scrollbar_width: u16, - scrollbar_margin: u16, - scroller_width: u16, + scrollbar_width: u32, + scrollbar_margin: u32, + scroller_width: u32, current_scroll_offset: scrollable::RelativeOffset, anchor: scrollable::Anchor, } @@ -39,9 +39,9 @@ enum Direction { enum Message { SwitchDirection(Direction), AlignmentChanged(scrollable::Anchor), - ScrollbarWidthChanged(u16), - ScrollbarMarginChanged(u16), - ScrollerWidthChanged(u16), + ScrollbarWidthChanged(u32), + ScrollbarMarginChanged(u32), + ScrollerWidthChanged(u32), ScrollToBeginning, ScrollToEnd, Scrolled(scrollable::Viewport), diff --git a/examples/tour/src/main.rs b/examples/tour/src/main.rs index 32720c478b..2ca1df443d 100644 --- a/examples/tour/src/main.rs +++ b/examples/tour/src/main.rs @@ -24,12 +24,12 @@ pub struct Tour { screen: Screen, slider: u8, layout: Layout, - spacing: u16, - text_size: u16, + spacing: u32, + text_size: u32, text_color: Color, language: Option, toggler: bool, - image_width: u16, + image_width: u32, image_filter_method: image::FilterMethod, input_value: String, input_is_secure: bool, @@ -43,11 +43,11 @@ pub enum Message { NextPressed, SliderChanged(u8), LayoutChanged(Layout), - SpacingChanged(u16), - TextSizeChanged(u16), + SpacingChanged(u32), + TextSizeChanged(u32), TextColorChanged(Color), LanguageSelected(Language), - ImageWidthChanged(u16), + ImageWidthChanged(u32), ImageUseNearestToggled(bool), InputChanged(String), ToggleSecureInput(bool), @@ -537,7 +537,7 @@ impl Screen { } fn ferris<'a>( - width: u16, + width: u32, filter_method: image::FilterMethod, ) -> Container<'a, Message> { center_x( From 17395e83201d1e5e00be46c1fb1d6fa18f3d87f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sun, 9 Feb 2025 06:40:48 +0100 Subject: [PATCH 3/6] Use `to_owned` instead of `to_string` in `gallery` example --- examples/gallery/src/civitai.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/gallery/src/civitai.rs b/examples/gallery/src/civitai.rs index 457091e9cb..8e57db3d53 100644 --- a/examples/gallery/src/civitai.rs +++ b/examples/gallery/src/civitai.rs @@ -71,7 +71,7 @@ impl Image { if part.starts_with("width=") { format!("width={width}") } else { - part.to_string() + part.to_owned() } }) .collect::>() From ec4d007a4cafa005a61aa24a47960e5d639b5587 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sun, 9 Feb 2025 08:02:05 +0100 Subject: [PATCH 4/6] Simplify `gallery` example a bit --- core/src/animation.rs | 12 ++ examples/gallery/src/civitai.rs | 2 +- examples/gallery/src/main.rs | 205 ++++++++++++++++---------------- 3 files changed, 115 insertions(+), 104 deletions(-) diff --git a/core/src/animation.rs b/core/src/animation.rs index 258fd084b7..14cbb5c39c 100644 --- a/core/src/animation.rs +++ b/core/src/animation.rs @@ -13,6 +13,7 @@ where T: Clone + Copy + PartialEq + Float, { raw: lilt::Animated, + duration: Duration, // TODO: Expose duration getter in `lilt` } impl Animation @@ -23,6 +24,7 @@ where pub fn new(state: T) -> Self { Self { raw: lilt::Animated::new(state), + duration: Duration::from_millis(100), } } @@ -58,6 +60,7 @@ where /// Sets the duration of the [`Animation`] to the given value. pub fn duration(mut self, duration: Duration) -> Self { self.raw = self.raw.duration(duration.as_secs_f32() * 1_000.0); + self.duration = duration; self } @@ -133,4 +136,13 @@ impl Animation { { self.raw.animate_bool(start, end, at) } + + /// Returns the remaining [`Duration`] of the [`Animation`]. + pub fn remaining(&self, at: Instant) -> Duration { + Duration::from_secs_f32(self.interpolate( + self.duration.as_secs_f32(), + 0.0, + at, + )) + } } diff --git a/examples/gallery/src/civitai.rs b/examples/gallery/src/civitai.rs index 8e57db3d53..18d2a04026 100644 --- a/examples/gallery/src/civitai.rs +++ b/examples/gallery/src/civitai.rs @@ -69,7 +69,7 @@ impl Image { .split("/") .map(|part| { if part.starts_with("width=") { - format!("width={width}") + format!("width={}", width * 2) // High DPI } else { part.to_owned() } diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 3bc663826a..1b28f28628 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -18,7 +18,6 @@ use iced::{ }; use std::collections::HashMap; -use std::time::Duration; fn main() -> iced::Result { iced::application("Gallery - Iced", Gallery::update, Gallery::view) @@ -68,7 +67,7 @@ impl Gallery { let is_animating = self .previews .values() - .any(|thumbnail| thumbnail.is_animating(self.now)) + .any(|preview| preview.is_animating(self.now)) || self.viewer.is_animating(self.now); if is_animating { @@ -114,28 +113,28 @@ impl Gallery { Task::none() } Message::ThumbnailDownloaded(id, Ok(rgba)) => { - let blurhash = match self.previews.remove(&id) { - Some(Preview::Blurhash(blurhash)) => Some(blurhash), - _ => None, + let thumbnail = if let Some(preview) = self.previews.remove(&id) + { + preview.load(rgba) + } else { + Preview::ready(rgba) }; - let _ = self - .previews - .insert(id, Preview::thumbnail(self.now, blurhash, rgba)); + let _ = self.previews.insert(id, thumbnail); Task::none() } Message::ThumbnailHovered(id, is_hovered) => { - if let Some(Preview::Thumbnail { zoom, .. }) = - self.previews.get_mut(&id) - { - zoom.go_mut(is_hovered); + if let Some(preview) = self.previews.get_mut(&id) { + preview.toggle_zoom(is_hovered); } Task::none() } Message::BlurhashDecoded(id, Ok(rgba)) => { - let _ = self.previews.insert(id, Preview::blurhash(rgba)); + if !self.previews.contains_key(&id) { + let _ = self.previews.insert(id, Preview::loading(rgba)); + } Task::none() } @@ -202,59 +201,38 @@ fn card<'a>( preview: Option<&'a Preview>, now: Instant, ) -> Element<'a, Message> { - let image: Element<'_, _> = match preview { - Some(Preview::Blurhash(Blurhash { handle, fade_in })) => image(handle) - .width(Fill) - .height(Fill) - .content_fit(ContentFit::Cover) - .opacity(fade_in.interpolate(0.0, Blurhash::MAX_OPACITY, now)) - .into(), - // Blurhash still needs to fade all the way in - Some(Preview::Thumbnail { - blurhash: Some(blurhash), - .. - }) if blurhash.fade_in.is_animating(now) => image(&blurhash.handle) - .width(Fill) - .height(Fill) - .content_fit(ContentFit::Cover) - .opacity(blurhash.fade_in.interpolate( - 0.0, - Blurhash::MAX_OPACITY, - now, - )) - .into(), - Some(Preview::Thumbnail { - blurhash, + let image = if let Some(preview) = preview { + let thumbnail: Element<'_, _> = if let Preview::Ready { thumbnail, fade_in, zoom, - }) => stack![] - // Transition between blurhash & thumbnail over the fade-in period - .push_maybe( - blurhash.as_ref().filter(|_| fade_in.is_animating(now)).map( - |blurhash| { - image(&blurhash.handle) - .width(Fill) - .height(Fill) - .content_fit(ContentFit::Cover) - .opacity(fade_in.interpolate( - Blurhash::MAX_OPACITY, - 0.0, - now, - )) - }, - ), - ) - .push( - image(thumbnail) - .width(Fill) - .height(Fill) - .content_fit(ContentFit::Cover) - .opacity(fade_in.interpolate(0.0, 1.0, now)) - .scale(zoom.interpolate(1.0, 1.1, now)), - ) - .into(), - None => horizontal_space().into(), + .. + } = &preview + { + image(thumbnail) + .width(Fill) + .height(Fill) + .content_fit(ContentFit::Cover) + .opacity(fade_in.interpolate(0.0, 1.0, now)) + .scale(zoom.interpolate(1.0, 1.1, now)) + .into() + } else { + horizontal_space().into() + }; + + if let Some(blurhash) = preview.blurhash(now) { + let blurhash = image(&blurhash.handle) + .width(Fill) + .height(Fill) + .content_fit(ContentFit::Cover) + .opacity(blurhash.fade_in.interpolate(0.0, 1.0, now)); + + stack![blurhash, thumbnail].into() + } else { + thumbnail + } + } else { + horizontal_space().into() }; let card = mouse_area( @@ -267,7 +245,7 @@ fn card<'a>( .on_exit(Message::ThumbnailHovered(metadata.id, false)); if let Some(preview) = preview { - let is_thumbnail = matches!(preview, Preview::Thumbnail { .. }); + let is_thumbnail = matches!(preview, Preview::Ready { .. }); button(card) .on_press_maybe(is_thumbnail.then_some(Message::Open(metadata.id))) @@ -289,19 +267,11 @@ fn placeholder<'a>() -> Element<'a, Message> { .into() } -struct Blurhash { - fade_in: Animation, - handle: image::Handle, -} - -impl Blurhash { - const FADE_IN: Duration = Duration::from_millis(200); - const MAX_OPACITY: f32 = 0.6; -} - enum Preview { - Blurhash(Blurhash), - Thumbnail { + Loading { + blurhash: Blurhash, + }, + Ready { blurhash: Option, thumbnail: image::Handle, fade_in: Animation, @@ -309,59 +279,88 @@ enum Preview { }, } +struct Blurhash { + handle: image::Handle, + fade_in: Animation, +} + impl Preview { const WIDTH: u32 = 320; const HEIGHT: u32 = 410; - fn blurhash(rgba: Rgba) -> Self { - Self::Blurhash(Blurhash { - fade_in: Animation::new(false).duration(Blurhash::FADE_IN).go(true), - handle: image::Handle::from_rgba( + fn loading(rgba: Rgba) -> Self { + Self::Loading { + blurhash: Blurhash { + fade_in: Animation::new(false).slow().go(true), + handle: image::Handle::from_rgba( + rgba.width, + rgba.height, + rgba.pixels, + ), + }, + } + } + + fn ready(rgba: Rgba) -> Self { + Self::Ready { + blurhash: None, + thumbnail: 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 thumbnail(now: Instant, blurhash: Option, rgba: Rgba) -> Self { - // Delay the thumbnail fade in until blurhash is fully - // faded in itself - let delay = blurhash - .as_ref() - .map(|blurhash| { - Duration::from_secs_f32(blurhash.fade_in.interpolate( - 0.0, - Blurhash::FADE_IN.as_secs_f32(), - now, - )) - }) - .unwrap_or_default(); - - Self::Thumbnail { - blurhash, + fn load(self, rgba: Rgba) -> Self { + let Self::Loading { blurhash } = self else { + return self; + }; + + Self::Ready { + blurhash: Some(blurhash), thumbnail: image::Handle::from_rgba( rgba.width, rgba.height, rgba.pixels, ), - fade_in: Animation::new(false).very_slow().delay(delay).go(true), + fade_in: Animation::new(false).slow().go(true), zoom: Animation::new(false) .quick() .easing(animation::Easing::EaseInOut), } } + fn toggle_zoom(&mut self, enabled: bool) { + if let Self::Ready { zoom, .. } = self { + zoom.go_mut(enabled); + } + } + fn is_animating(&self, now: Instant) -> bool { - match self { - Preview::Blurhash(Blurhash { fade_in, .. }) => { - fade_in.is_animating(now) - } - Preview::Thumbnail { fade_in, zoom, .. } => { + match &self { + Self::Loading { blurhash } => blurhash.fade_in.is_animating(now), + Self::Ready { fade_in, zoom, .. } => { fade_in.is_animating(now) || zoom.is_animating(now) } } } + + fn blurhash(&self, now: Instant) -> Option<&Blurhash> { + match self { + Self::Loading { blurhash, .. } => Some(blurhash), + Self::Ready { + blurhash: Some(blurhash), + fade_in, + .. + } if fade_in.is_animating(now) => Some(blurhash), + _ => None, + } + } } struct Viewer { From 80ccbb835774cbd462076b35464514682d13459e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sun, 9 Feb 2025 08:12:04 +0100 Subject: [PATCH 5/6] Create explicit `Thumbnail` struct in `gallery` example --- Cargo.toml | 1 + examples/gallery/src/main.rs | 90 ++++++++++++++++++------------------ 2 files changed, 46 insertions(+), 45 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d365e14696..c95fbd1e78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -200,6 +200,7 @@ unused_results = "deny" [workspace.lints.clippy] type-complexity = "allow" +map-entry = "allow" semicolon_if_nothing_returned = "deny" trivially-copy-pass-by-ref = "deny" default_trait_access = "deny" diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 1b28f28628..4728129e83 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -202,23 +202,18 @@ fn card<'a>( now: Instant, ) -> Element<'a, Message> { let image = if let Some(preview) = preview { - let thumbnail: Element<'_, _> = if let Preview::Ready { - thumbnail, - fade_in, - zoom, - .. - } = &preview - { - image(thumbnail) - .width(Fill) - .height(Fill) - .content_fit(ContentFit::Cover) - .opacity(fade_in.interpolate(0.0, 1.0, now)) - .scale(zoom.interpolate(1.0, 1.1, now)) - .into() - } else { - horizontal_space().into() - }; + let thumbnail: Element<'_, _> = + if let Preview::Ready { thumbnail, .. } = &preview { + 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() + }; if let Some(blurhash) = preview.blurhash(now) { let blurhash = image(&blurhash.handle) @@ -273,9 +268,7 @@ enum Preview { }, Ready { blurhash: Option, - thumbnail: image::Handle, - fade_in: Animation, - zoom: Animation, + thumbnail: Thumbnail, }, } @@ -284,6 +277,12 @@ struct Blurhash { fade_in: Animation, } +struct Thumbnail { + handle: image::Handle, + fade_in: Animation, + zoom: Animation, +} + impl Preview { const WIDTH: u32 = 320; const HEIGHT: u32 = 410; @@ -304,15 +303,7 @@ impl Preview { fn ready(rgba: Rgba) -> Self { Self::Ready { blurhash: None, - thumbnail: 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), + thumbnail: Thumbnail::new(rgba), } } @@ -323,29 +314,22 @@ impl Preview { Self::Ready { blurhash: Some(blurhash), - thumbnail: 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), + thumbnail: Thumbnail::new(rgba), } } fn toggle_zoom(&mut self, enabled: bool) { - if let Self::Ready { zoom, .. } = self { - zoom.go_mut(enabled); + if let Self::Ready { thumbnail, .. } = self { + thumbnail.zoom.go_mut(enabled); } } fn is_animating(&self, now: Instant) -> bool { match &self { Self::Loading { blurhash } => blurhash.fade_in.is_animating(now), - Self::Ready { fade_in, zoom, .. } => { - fade_in.is_animating(now) || zoom.is_animating(now) + Self::Ready { thumbnail, .. } => { + thumbnail.fade_in.is_animating(now) + || thumbnail.zoom.is_animating(now) } } } @@ -355,10 +339,26 @@ impl Preview { Self::Loading { blurhash, .. } => Some(blurhash), Self::Ready { blurhash: Some(blurhash), - fade_in, + thumbnail, .. - } if fade_in.is_animating(now) => Some(blurhash), - _ => None, + } if thumbnail.fade_in.is_animating(now) => Some(blurhash), + Self::Ready { .. } => None, + } + } +} + +impl Thumbnail { + pub 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), } } } From e0d60d58391535d96f0864da4a4dde50d5669543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sun, 9 Feb 2025 08:28:29 +0100 Subject: [PATCH 6/6] Increase blurhash fade in duration and adjust easing in `gallery` example --- examples/gallery/src/main.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 4728129e83..ab22679d92 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -7,7 +7,7 @@ mod civitai; use crate::civitai::{Error, Id, Image, Rgba, Size}; use iced::animation; -use iced::time::Instant; +use iced::time::{milliseconds, Instant}; use iced::widget::{ button, center_x, container, horizontal_space, image, mouse_area, opaque, pop, row, scrollable, stack, @@ -290,7 +290,10 @@ impl Preview { fn loading(rgba: Rgba) -> Self { Self::Loading { blurhash: Blurhash { - fade_in: Animation::new(false).slow().go(true), + fade_in: Animation::new(false) + .duration(milliseconds(700)) + .easing(animation::Easing::EaseIn) + .go(true), handle: image::Handle::from_rgba( rgba.width, rgba.height,