diff --git a/flake.nix b/flake.nix index e4b3c3c..0589766 100644 --- a/flake.nix +++ b/flake.nix @@ -32,6 +32,8 @@ libxcb ] ++ lib.optionals stdenv.hostPlatform.isLinux [ + dbus.lib + vulkan-loader wayland ]; diff --git a/src/app/maybe_image.rs b/src/app/maybe_image.rs new file mode 100644 index 0000000..c8fff05 --- /dev/null +++ b/src/app/maybe_image.rs @@ -0,0 +1,28 @@ +use iced::{task, widget}; +use image::DynamicImage; + +#[derive(Clone, Debug, Default)] +pub enum MaybeImage { + #[default] + Unloaded, + + #[allow(dead_code)] + /// should be abort_on_drop, + /// so that it will abort, for instance, + /// when it's set back to `Self::Unloaded` + Loading(task::Handle), + + Loaded(widget::image::Handle), +} +impl MaybeImage { + pub fn handle(&self) -> Option<&widget::image::Handle> { + match self { + Self::Loaded(h) => Some(&h), + _ => None, + } + } +} + +pub fn handle_from_image(image: DynamicImage) -> widget::image::Handle { + widget::image::Handle::from_rgba(image.width(), image.height(), image.into_rgba8().into_raw()) +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 4d51ac0..8ef9843 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,27 +1,46 @@ -use std::{borrow::Cow, collections::HashMap, env, path::PathBuf, process}; +use std::{ + borrow::Cow, + collections::BTreeMap, + env, + path::{Path, PathBuf}, + process, + sync::Arc, +}; use iced::{ Color, Element, Event, Length, Renderer, Subscription, Task, Theme, application, event, keyboard, theme, widget, window, }; use ignore::{WalkBuilder, types::TypesBuilder}; +use image::ImageError; use rayon::iter::{ParallelBridge, ParallelIterator}; +use rfd::AsyncFileDialog; -use crate::SUPPORTED_TYPES; +use crate::{ + SUPPORTED_TYPES, + app::maybe_image::{MaybeImage, handle_from_image}, + utils, +}; + +mod maybe_image; #[derive(Clone, Debug)] enum Message { LoadImage(PathBuf), + ImageLoaded(Result<(PathBuf, widget::image::Handle), Arc>), + UnloadImage(PathBuf), OpenImage(PathBuf), + OpenPath(Option), + KeyPressed(keyboard::Key, keyboard::Modifiers), } #[derive(Default)] struct State { - images: HashMap>, + images: BTreeMap, columns: usize, @@ -29,47 +48,58 @@ struct State { } impl State { - fn new() -> Self { - let mut builder = TypesBuilder::new(); - builder.add_defaults(); - for ext in SUPPORTED_TYPES { - builder.add("supportedImages", ext).unwrap(); - } - builder.select("supportedImages"); - - Self { - columns: 3, - error: None, - images: WalkBuilder::new( + fn new() -> (Self, Task) { + ( + Self { + columns: 3, + error: None, + images: BTreeMap::new(), + }, + Task::done(Message::OpenPath(Some( env::args() .nth(1) - .map(Cow::Owned) - .unwrap_or(Cow::Borrowed("./")) - .as_ref(), - ) - .types(builder.build().unwrap()) - .max_depth(Some(1)) - .build() - .par_bridge() - .filter_map(|r| { - r.inspect_err(|e| eprintln!("{e}")) - .ok() - .map(|entry| entry.into_path()) - }) - .filter(|path| path.is_file()) - .map(|path| (path, None)) - .collect(), - } + .unwrap_or_else(|| String::from("./")) + .into(), + ))), + ) } fn update(&mut self, message: Message) -> Task { match message { Message::LoadImage(path) => { - self.images - .entry(path.clone()) - .and_modify(|e| *e = Some(widget::image::Handle::from_path(path))); + let entry = self.images.entry(path.clone()).or_default(); + + if matches!(entry, MaybeImage::Unloaded) { + let size = 1000 / self.columns as u32; + let (task, handle) = Task::perform( + async move { + utils::load_thumbnail(&path, size) + .map(|img| (path, handle_from_image(img))) + .map_err(Arc::new) + }, + Message::ImageLoaded, + ) + .abortable(); + + *entry = MaybeImage::Loading(handle.abort_on_drop()); + return task; + } } + Message::ImageLoaded(result) => { + self.error = result.as_ref().err().map(|e| e.to_string()); + + if let Ok((path, handle)) = result { + let entry = self.images.entry(path).or_default(); + + if matches!(entry, MaybeImage::Loaded(_)) { + self.error = Some("tried to load already-loaded image!".into()) + } else { + *entry = MaybeImage::Loaded(handle) + } + } + } + Message::UnloadImage(path) => { - self.images.entry(path).and_modify(|e| *e = None); + self.images.entry(path).insert_entry(MaybeImage::Unloaded); } Message::OpenImage(path) => { @@ -80,6 +110,17 @@ impl State { .err() } + Message::OpenPath(opt_path) => { + self.error = match opt_path { + Some(_) => None, + None => Some("no path to open given".into()), + }; + + if let Some(path) = opt_path { + self.open_path(path); + } + } + Message::KeyPressed(key, modifiers) => match key.as_ref() { // input field cycling keyboard::Key::Named(keyboard::key::Named::Tab) => { @@ -90,6 +131,13 @@ impl State { }; } + keyboard::Key::Character("o") => { + return Task::perform( + async { AsyncFileDialog::new().pick_folder().await.map(Into::into) }, + Message::OpenPath, + ); + } + keyboard::Key::Character("=" | "+") => { self.columns = (self.columns + 1).clamp(1, 10) } @@ -109,18 +157,28 @@ impl State { fn view(&self) -> Element<'_, Message, Theme, Renderer> { widget::column([ widget::scrollable( - widget::grid(self.images.iter().map(|(path, opt_handle)| { - widget::button( + widget::grid(self.images.iter().map(|(path, maybe_image)| { + widget::button(widget::column([ widget::container( - widget::sensor(opt_handle.as_ref().map_or_else( + widget::sensor(maybe_image.handle().map_or_else( || Element::from(widget::space()), |handle| widget::image(handle).into(), )) + .anticipate(2000) .on_show(|_size| Message::LoadImage(path.clone())) .on_hide(Message::UnloadImage(path.clone())), ) - .center(Length::Fill), - ) + .center(Length::Fill) + .into(), + widget::text( + path.file_name() + .map_or(Cow::Borrowed("[no file]"), |extension| { + extension.to_string_lossy() + }), + ) + .center() + .into(), + ])) .style(widget::button::subtle) .on_press(Message::OpenImage(path.clone())) .into() @@ -129,13 +187,10 @@ impl State { ) .height(Length::Fill) .into(), - self.error - .as_ref() - .map_or_else( - || Element::from(widget::space()), - |error| widget::text(error).style(widget::text::danger).into(), - ) - .into(), + self.error.as_ref().map_or_else( + || Element::from(widget::space()), + |error| widget::text(error).style(widget::text::danger).into(), + ), ]) .into() } @@ -148,6 +203,29 @@ impl State { _ => None, }) } + + fn open_path(&mut self, path: impl AsRef) { + let mut builder = TypesBuilder::new(); + builder.add_defaults(); + for ext in SUPPORTED_TYPES { + builder.add("supportedImages", ext).unwrap(); + } + builder.select("supportedImages"); + + self.images = WalkBuilder::new(path) + .types(builder.build().unwrap()) + .max_depth(Some(1)) + .build() + .par_bridge() + .filter_map(|r| { + r.inspect_err(|e| eprintln!("{e}")) + .ok() + .map(|entry| entry.into_path()) + }) + .filter(|path| path.is_file()) + .map(|path| (path, MaybeImage::Unloaded)) + .collect(); + } } pub fn run() -> Result<(), impl std::error::Error> { diff --git a/src/main.rs b/src/main.rs index 8a836ad..96bfe83 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod app; +mod utils; const SUPPORTED_TYPES: &[&str; 20] = &[ "*.png", "*.PNG", "*.jpg", "*.JPG", "*.jpeg", "*.JPEG", "*.avif", "*.jxl", "*.bmp", "*.exr", diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..3eb1795 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,27 @@ +use std::{ffi::OsStr, path::Path}; + +use image::{DynamicImage, ImageDecoder, ImageReader, ImageResult}; + +pub fn load_thumbnail(path: impl AsRef, max_size: u32) -> ImageResult { + let mut decoder = ImageReader::open(&path)? + .with_guessed_format()? + .into_decoder()?; + + let oreintation = decoder.orientation()?; + + let mut decoded_image = DynamicImage::from_decoder(decoder)?.thumbnail(max_size, max_size); + + // the condition is a workaround to not rotate JXL images twice; + // since they are already rotated by the decoder + // (while jpegs for instance aren't) + if !path + .as_ref() + .extension() + .map(OsStr::to_string_lossy) + .is_some_and(|s| s == "jxl") + { + decoded_image.apply_orientation(oreintation); + } + + Ok(decoded_image) +}