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/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/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/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/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..18d2a04026 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 * 2) // High DPI } else { - part + part.to_owned() } }) .collect::>() @@ -107,7 +125,7 @@ impl fmt::Debug for Rgba { #[derive(Debug, Clone, Copy)] pub enum Size { Original, - Thumbnail, + Thumbnail { width: u32 }, } #[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..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, @@ -28,7 +28,7 @@ fn main() -> iced::Result { struct Gallery { images: Vec, - thumbnails: HashMap, + previews: HashMap, viewer: Viewer, now: Instant, } @@ -40,6 +40,7 @@ enum Message { ImageDownloaded(Result), ThumbnailDownloaded(Id, Result), ThumbnailHovered(Id, bool), + BlurhashDecoded(Id, Result), Open(Id), Close, Animate(Instant), @@ -50,7 +51,7 @@ impl Gallery { ( Self { images: Vec::new(), - thumbnails: HashMap::new(), + previews: HashMap::new(), viewer: Viewer::new(), now: Instant::now(), }, @@ -64,9 +65,9 @@ impl Gallery { pub fn subscription(&self) -> Subscription { let is_animating = self - .thumbnails + .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 { @@ -93,9 +94,18 @@ 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, Preview::HEIGHT), + 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,14 +113,27 @@ impl Gallery { Task::none() } Message::ThumbnailDownloaded(id, Ok(rgba)) => { - let thumbnail = Thumbnail::new(rgba); - let _ = self.thumbnails.insert(id, thumbnail); + let thumbnail = if let Some(preview) = self.previews.remove(&id) + { + preview.load(rgba) + } else { + Preview::ready(rgba) + }; + + let _ = self.previews.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); + if let Some(preview) = self.previews.get_mut(&id) { + preview.toggle_zoom(is_hovered); + } + + Task::none() + } + Message::BlurhashDecoded(id, Ok(rgba)) => { + if !self.previews.contains_key(&id) { + let _ = self.previews.insert(id, Preview::loading(rgba)); } Task::none() @@ -144,7 +167,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 +181,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 +198,52 @@ 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) - .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() + let image = if let Some(preview) = preview { + 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) + .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( 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::Ready { .. }); + 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,23 +256,102 @@ 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() } +enum Preview { + Loading { + blurhash: Blurhash, + }, + Ready { + blurhash: Option, + thumbnail: Thumbnail, + }, +} + +struct Blurhash { + handle: image::Handle, + fade_in: Animation, +} + struct Thumbnail { handle: image::Handle, fade_in: Animation, zoom: Animation, } -impl Thumbnail { - const WIDTH: u16 = 320; - const HEIGHT: u16 = 410; +impl Preview { + const WIDTH: u32 = 320; + const HEIGHT: u32 = 410; + + fn loading(rgba: Rgba) -> Self { + Self::Loading { + blurhash: Blurhash { + fade_in: Animation::new(false) + .duration(milliseconds(700)) + .easing(animation::Easing::EaseIn) + .go(true), + handle: image::Handle::from_rgba( + rgba.width, + rgba.height, + rgba.pixels, + ), + }, + } + } - fn new(rgba: Rgba) -> Self { + fn ready(rgba: Rgba) -> Self { + Self::Ready { + blurhash: None, + thumbnail: Thumbnail::new(rgba), + } + } + + fn load(self, rgba: Rgba) -> Self { + let Self::Loading { blurhash } = self else { + return self; + }; + + Self::Ready { + blurhash: Some(blurhash), + thumbnail: Thumbnail::new(rgba), + } + } + + fn toggle_zoom(&mut self, enabled: bool) { + 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 { thumbnail, .. } => { + thumbnail.fade_in.is_animating(now) + || thumbnail.zoom.is_animating(now) + } + } + } + + fn blurhash(&self, now: Instant) -> Option<&Blurhash> { + match self { + Self::Loading { blurhash, .. } => Some(blurhash), + Self::Ready { + blurhash: Some(blurhash), + thumbnail, + .. + } 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, @@ -242,10 +364,6 @@ impl Thumbnail { .easing(animation::Easing::EaseInOut), } } - - fn is_animating(&self, now: Instant) -> bool { - self.fade_in.is_animating(now) || self.zoom.is_animating(now) - } } struct Viewer { 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(