From 6312d0a6106640a74f40da289fd282703c94e2c4 Mon Sep 17 00:00:00 2001 From: electria Date: Sun, 9 Aug 2026 13:43:04 -0700 Subject: [PATCH 01/10] feat: show filename under thumbnail --- src/app/mod.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 4d51ac0..9096821 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -110,7 +110,7 @@ impl State { widget::column([ widget::scrollable( widget::grid(self.images.iter().map(|(path, opt_handle)| { - widget::button( + widget::button(widget::column([ widget::container( widget::sensor(opt_handle.as_ref().map_or_else( || Element::from(widget::space()), @@ -119,8 +119,17 @@ impl State { .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() From 0c8149bd53c80a6935bdda01834295acedf462c0 Mon Sep 17 00:00:00 2001 From: electria Date: Sun, 9 Aug 2026 13:43:54 -0700 Subject: [PATCH 02/10] feat: ordered paths this isn't a very robust solution, but it is efficent and works on basic filenames. --- src/app/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 9096821..50a00b7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,4 +1,4 @@ -use std::{borrow::Cow, collections::HashMap, env, path::PathBuf, process}; +use std::{borrow::Cow, collections::BTreeMap, env, path::PathBuf, process}; use iced::{ Color, Element, Event, Length, Renderer, Subscription, Task, Theme, application, event, @@ -21,7 +21,7 @@ enum Message { #[derive(Default)] struct State { - images: HashMap>, + images: BTreeMap>, columns: usize, From d6549939a8e5693700272dca125ab9cefcca82ac Mon Sep 17 00:00:00 2001 From: electria Date: Sun, 9 Aug 2026 14:04:44 -0700 Subject: [PATCH 03/10] feat: pick folder dialog --- flake.nix | 2 ++ src/app/mod.rs | 93 ++++++++++++++++++++++++++++++++++---------------- 2 files changed, 65 insertions(+), 30 deletions(-) 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/mod.rs b/src/app/mod.rs index 50a00b7..6033d8a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,4 +1,10 @@ -use std::{borrow::Cow, collections::BTreeMap, env, path::PathBuf, process}; +use std::{ + borrow::Cow, + collections::BTreeMap, + env, + path::{Path, PathBuf}, + process, +}; use iced::{ Color, Element, Event, Length, Renderer, Subscription, Task, Theme, application, event, @@ -6,6 +12,7 @@ use iced::{ }; use ignore::{WalkBuilder, types::TypesBuilder}; use rayon::iter::{ParallelBridge, ParallelIterator}; +use rfd::AsyncFileDialog; use crate::SUPPORTED_TYPES; @@ -16,6 +23,8 @@ enum Message { OpenImage(PathBuf), + OpenPath(Option), + KeyPressed(keyboard::Key, keyboard::Modifiers), } @@ -29,37 +38,20 @@ 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 { @@ -80,6 +72,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 +93,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) } @@ -157,6 +167,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, None)) + .collect(); + } } pub fn run() -> Result<(), impl std::error::Error> { From 05c5f7d37a1122a387606074300253afa731678c Mon Sep 17 00:00:00 2001 From: electria Date: Sun, 9 Aug 2026 14:19:36 -0700 Subject: [PATCH 04/10] refactor: apply clippy lint --- src/app/mod.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 6033d8a..427c063 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -148,13 +148,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() } From eaf0c674ab4d361561ac4b7d2c181943e1173e50 Mon Sep 17 00:00:00 2001 From: electria Date: Sun, 9 Aug 2026 14:40:30 -0700 Subject: [PATCH 05/10] refactor: prepare for parrelel loading with MaybeImage --- src/app/maybe_image.rs | 30 ++++++++++++++++++++++++++++++ src/app/mod.rs | 20 +++++++++++--------- 2 files changed, 41 insertions(+), 9 deletions(-) create mode 100644 src/app/maybe_image.rs diff --git a/src/app/maybe_image.rs b/src/app/maybe_image.rs new file mode 100644 index 0000000..1984854 --- /dev/null +++ b/src/app/maybe_image.rs @@ -0,0 +1,30 @@ +use iced::{task, widget}; +use image::DynamicImage; + +#[derive(Clone, Debug, Default)] +pub enum MaybeImage { + #[default] + 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 unload(&mut self) { + match self { + Self::Loading(task_handle) => task_handle.abort(), + _ => {} + } + *self = Self::Unloaded; + } +} + +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 427c063..f2a43f2 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -14,7 +14,9 @@ use ignore::{WalkBuilder, types::TypesBuilder}; use rayon::iter::{ParallelBridge, ParallelIterator}; use rfd::AsyncFileDialog; -use crate::SUPPORTED_TYPES; +use crate::{SUPPORTED_TYPES, app::maybe_image::MaybeImage}; + +mod maybe_image; #[derive(Clone, Debug)] enum Message { @@ -30,7 +32,7 @@ enum Message { #[derive(Default)] struct State { - images: BTreeMap>, + images: BTreeMap, columns: usize, @@ -56,12 +58,12 @@ impl State { 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))); + self.images.entry(path.clone()).and_modify(|e| { + *e = MaybeImage::Loaded(widget::image::Handle::from_path(path)) + }); } Message::UnloadImage(path) => { - self.images.entry(path).and_modify(|e| *e = None); + self.images.entry(path).and_modify(MaybeImage::unload); } Message::OpenImage(path) => { @@ -119,10 +121,10 @@ impl State { fn view(&self) -> Element<'_, Message, Theme, Renderer> { widget::column([ widget::scrollable( - widget::grid(self.images.iter().map(|(path, opt_handle)| { + 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(), )) @@ -184,7 +186,7 @@ impl State { .map(|entry| entry.into_path()) }) .filter(|path| path.is_file()) - .map(|path| (path, None)) + .map(|path| (path, MaybeImage::Unloaded)) .collect(); } } From e179b824c8f1aeaf52e24569f934c54570ea2dbe Mon Sep 17 00:00:00 2001 From: electria Date: Sun, 9 Aug 2026 15:10:16 -0700 Subject: [PATCH 06/10] feat: parallel loading --- src/app/mod.rs | 38 +++++++++++++++++++++++++++++++++++--- src/main.rs | 1 + src/utils.rs | 9 +++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 src/utils.rs diff --git a/src/app/mod.rs b/src/app/mod.rs index f2a43f2..496479c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -4,6 +4,7 @@ use std::{ env, path::{Path, PathBuf}, process, + sync::Arc, }; use iced::{ @@ -11,16 +12,23 @@ use iced::{ keyboard, theme, widget, window, }; use ignore::{WalkBuilder, types::TypesBuilder}; +use image::ImageError; use rayon::iter::{ParallelBridge, ParallelIterator}; use rfd::AsyncFileDialog; -use crate::{SUPPORTED_TYPES, app::maybe_image::MaybeImage}; +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), @@ -58,10 +66,34 @@ impl State { fn update(&mut self, message: Message) -> Task { match message { Message::LoadImage(path) => { - self.images.entry(path.clone()).and_modify(|e| { - *e = MaybeImage::Loaded(widget::image::Handle::from_path(path)) + let path_clone = path.clone(); + + let (task, handle) = Task::perform( + async { + utils::load_image(&path) + .map(|img| (path, handle_from_image(img))) + .map_err(Arc::new) + }, + Message::ImageLoaded, + ) + .abortable(); + + self.images.entry(path_clone).and_modify(|e| { + *e = MaybeImage::Loading(handle); }); + + return task; } + Message::ImageLoaded(result) => { + self.error = result.as_ref().err().map(|e| e.to_string()); + + if let Ok((path, handle)) = result { + self.images + .entry(path) + .and_modify(|e| *e = MaybeImage::Loaded(handle)); + } + } + Message::UnloadImage(path) => { self.images.entry(path).and_modify(MaybeImage::unload); } 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..9baabee --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,9 @@ +use std::path::Path; + +use image::{DynamicImage, ImageReader, ImageResult}; + +pub fn load_image(path: impl AsRef) -> ImageResult { + Ok(DynamicImage::from_decoder( + ImageReader::open(path)?.into_decoder()?, + )?) +} From 26268f97715503ef0b13b254a0a097f0502bf508 Mon Sep 17 00:00:00 2001 From: electria Date: Sun, 9 Aug 2026 15:27:38 -0700 Subject: [PATCH 07/10] fix: two possible causes of flickering still flickers, likely due to aborting not working since the async code isn't really async... --- src/app/maybe_image.rs | 14 ++++++-------- src/app/mod.rs | 31 +++++++++++++++---------------- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/src/app/maybe_image.rs b/src/app/maybe_image.rs index 1984854..c8fff05 100644 --- a/src/app/maybe_image.rs +++ b/src/app/maybe_image.rs @@ -5,7 +5,13 @@ use image::DynamicImage; 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 { @@ -15,14 +21,6 @@ impl MaybeImage { _ => None, } } - - pub fn unload(&mut self) { - match self { - Self::Loading(task_handle) => task_handle.abort(), - _ => {} - } - *self = Self::Unloaded; - } } pub fn handle_from_image(image: DynamicImage) -> widget::image::Handle { diff --git a/src/app/mod.rs b/src/app/mod.rs index 496479c..b384ebd 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -66,23 +66,22 @@ impl State { fn update(&mut self, message: Message) -> Task { match message { Message::LoadImage(path) => { - let path_clone = path.clone(); + let entry = self.images.entry(path.clone()).or_default(); - let (task, handle) = Task::perform( - async { - utils::load_image(&path) - .map(|img| (path, handle_from_image(img))) - .map_err(Arc::new) - }, - Message::ImageLoaded, - ) - .abortable(); + if matches!(entry, MaybeImage::Unloaded) { + let (task, handle) = Task::perform( + async { + utils::load_image(&path) + .map(|img| (path, handle_from_image(img))) + .map_err(Arc::new) + }, + Message::ImageLoaded, + ) + .abortable(); - self.images.entry(path_clone).and_modify(|e| { - *e = MaybeImage::Loading(handle); - }); - - return task; + *entry = MaybeImage::Loading(handle.abort_on_drop()); + return task; + } } Message::ImageLoaded(result) => { self.error = result.as_ref().err().map(|e| e.to_string()); @@ -95,7 +94,7 @@ impl State { } Message::UnloadImage(path) => { - self.images.entry(path).and_modify(MaybeImage::unload); + self.images.entry(path).insert_entry(MaybeImage::Unloaded); } Message::OpenImage(path) => { From d9751fe2e6e612f0f402433a57f57881568016da Mon Sep 17 00:00:00 2001 From: electria Date: Sun, 9 Aug 2026 15:32:24 -0700 Subject: [PATCH 08/10] fix: flickering it does still reload the image, but at least it doesn't flicker while doing so this also exposes a problem with the error reporting, since with so many error-reporting operations going on, the errors that do happen are most often immediately cleared. --- src/app/mod.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index b384ebd..eeae390 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -87,9 +87,13 @@ impl State { self.error = result.as_ref().err().map(|e| e.to_string()); if let Ok((path, handle)) = result { - self.images - .entry(path) - .and_modify(|e| *e = MaybeImage::Loaded(handle)); + 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) + } } } From 7d45896a6f3134851440f2aaa74e42e7c5fe3816 Mon Sep 17 00:00:00 2001 From: electria Date: Sun, 9 Aug 2026 15:53:48 -0700 Subject: [PATCH 09/10] perf: create thumbnails, preload --- src/app/mod.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index eeae390..d42be8e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -69,10 +69,14 @@ impl State { let entry = self.images.entry(path.clone()).or_default(); if matches!(entry, MaybeImage::Unloaded) { + let columns = self.columns; let (task, handle) = Task::perform( - async { + async move { utils::load_image(&path) - .map(|img| (path, handle_from_image(img))) + .map(|img| { + let size = 1000 / columns as u32; + (path, handle_from_image(img.thumbnail(size, size))) + }) .map_err(Arc::new) }, Message::ImageLoaded, @@ -163,6 +167,7 @@ impl State { || Element::from(widget::space()), |handle| widget::image(handle).into(), )) + .anticipate(2000) .on_show(|_size| Message::LoadImage(path.clone())) .on_hide(Message::UnloadImage(path.clone())), ) From 1e46fed95ad4896d8c0aca23990af376a1c878f4 Mon Sep 17 00:00:00 2001 From: electria Date: Sun, 9 Aug 2026 15:59:23 -0700 Subject: [PATCH 10/10] fix: pull oreintation workaround from imagey it also made sense to create the thumnail in the function, since it is easier to rotate a smaller image. (this is fine because max_width == max_height) --- src/app/mod.rs | 9 +++------ src/utils.rs | 30 ++++++++++++++++++++++++------ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index d42be8e..8ef9843 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -69,14 +69,11 @@ impl State { let entry = self.images.entry(path.clone()).or_default(); if matches!(entry, MaybeImage::Unloaded) { - let columns = self.columns; + let size = 1000 / self.columns as u32; let (task, handle) = Task::perform( async move { - utils::load_image(&path) - .map(|img| { - let size = 1000 / columns as u32; - (path, handle_from_image(img.thumbnail(size, size))) - }) + utils::load_thumbnail(&path, size) + .map(|img| (path, handle_from_image(img))) .map_err(Arc::new) }, Message::ImageLoaded, diff --git a/src/utils.rs b/src/utils.rs index 9baabee..3eb1795 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,9 +1,27 @@ -use std::path::Path; +use std::{ffi::OsStr, path::Path}; -use image::{DynamicImage, ImageReader, ImageResult}; +use image::{DynamicImage, ImageDecoder, ImageReader, ImageResult}; -pub fn load_image(path: impl AsRef) -> ImageResult { - Ok(DynamicImage::from_decoder( - ImageReader::open(path)?.into_decoder()?, - )?) +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) }