Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
1e46fed95a |
|||
|
7d45896a6f |
|||
|
d9751fe2e6 |
|||
|
26268f9771 |
|||
|
e179b824c8 |
|||
|
eaf0c674ab |
|||
|
05c5f7d37a |
|||
|
d6549939a8 |
|||
|
0c8149bd53 |
|||
|
6312d0a610 |
5 changed files with 184 additions and 48 deletions
|
|
@ -32,6 +32,8 @@
|
||||||
libxcb
|
libxcb
|
||||||
]
|
]
|
||||||
++ lib.optionals stdenv.hostPlatform.isLinux [
|
++ lib.optionals stdenv.hostPlatform.isLinux [
|
||||||
|
dbus.lib
|
||||||
|
|
||||||
vulkan-loader
|
vulkan-loader
|
||||||
wayland
|
wayland
|
||||||
];
|
];
|
||||||
|
|
|
||||||
28
src/app/maybe_image.rs
Normal file
28
src/app/maybe_image.rs
Normal file
|
|
@ -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())
|
||||||
|
}
|
||||||
174
src/app/mod.rs
174
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::{
|
use iced::{
|
||||||
Color, Element, Event, Length, Renderer, Subscription, Task, Theme, application, event,
|
Color, Element, Event, Length, Renderer, Subscription, Task, Theme, application, event,
|
||||||
keyboard, theme, widget, window,
|
keyboard, theme, widget, window,
|
||||||
};
|
};
|
||||||
use ignore::{WalkBuilder, types::TypesBuilder};
|
use ignore::{WalkBuilder, types::TypesBuilder};
|
||||||
|
use image::ImageError;
|
||||||
use rayon::iter::{ParallelBridge, ParallelIterator};
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
enum Message {
|
enum Message {
|
||||||
LoadImage(PathBuf),
|
LoadImage(PathBuf),
|
||||||
|
ImageLoaded(Result<(PathBuf, widget::image::Handle), Arc<ImageError>>),
|
||||||
|
|
||||||
UnloadImage(PathBuf),
|
UnloadImage(PathBuf),
|
||||||
|
|
||||||
OpenImage(PathBuf),
|
OpenImage(PathBuf),
|
||||||
|
|
||||||
|
OpenPath(Option<PathBuf>),
|
||||||
|
|
||||||
KeyPressed(keyboard::Key, keyboard::Modifiers),
|
KeyPressed(keyboard::Key, keyboard::Modifiers),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct State {
|
struct State {
|
||||||
images: HashMap<PathBuf, Option<widget::image::Handle>>,
|
images: BTreeMap<PathBuf, MaybeImage>,
|
||||||
|
|
||||||
columns: usize,
|
columns: usize,
|
||||||
|
|
||||||
|
|
@ -29,47 +48,58 @@ struct State {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl State {
|
impl State {
|
||||||
fn new() -> Self {
|
fn new() -> (Self, Task<Message>) {
|
||||||
let mut builder = TypesBuilder::new();
|
(
|
||||||
builder.add_defaults();
|
Self {
|
||||||
for ext in SUPPORTED_TYPES {
|
columns: 3,
|
||||||
builder.add("supportedImages", ext).unwrap();
|
error: None,
|
||||||
}
|
images: BTreeMap::new(),
|
||||||
builder.select("supportedImages");
|
},
|
||||||
|
Task::done(Message::OpenPath(Some(
|
||||||
Self {
|
|
||||||
columns: 3,
|
|
||||||
error: None,
|
|
||||||
images: WalkBuilder::new(
|
|
||||||
env::args()
|
env::args()
|
||||||
.nth(1)
|
.nth(1)
|
||||||
.map(Cow::Owned)
|
.unwrap_or_else(|| String::from("./"))
|
||||||
.unwrap_or(Cow::Borrowed("./"))
|
.into(),
|
||||||
.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(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fn update(&mut self, message: Message) -> Task<Message> {
|
fn update(&mut self, message: Message) -> Task<Message> {
|
||||||
match message {
|
match message {
|
||||||
Message::LoadImage(path) => {
|
Message::LoadImage(path) => {
|
||||||
self.images
|
let entry = self.images.entry(path.clone()).or_default();
|
||||||
.entry(path.clone())
|
|
||||||
.and_modify(|e| *e = Some(widget::image::Handle::from_path(path)));
|
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) => {
|
Message::UnloadImage(path) => {
|
||||||
self.images.entry(path).and_modify(|e| *e = None);
|
self.images.entry(path).insert_entry(MaybeImage::Unloaded);
|
||||||
}
|
}
|
||||||
|
|
||||||
Message::OpenImage(path) => {
|
Message::OpenImage(path) => {
|
||||||
|
|
@ -80,6 +110,17 @@ impl State {
|
||||||
.err()
|
.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() {
|
Message::KeyPressed(key, modifiers) => match key.as_ref() {
|
||||||
// input field cycling
|
// input field cycling
|
||||||
keyboard::Key::Named(keyboard::key::Named::Tab) => {
|
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("=" | "+") => {
|
keyboard::Key::Character("=" | "+") => {
|
||||||
self.columns = (self.columns + 1).clamp(1, 10)
|
self.columns = (self.columns + 1).clamp(1, 10)
|
||||||
}
|
}
|
||||||
|
|
@ -109,18 +157,28 @@ impl State {
|
||||||
fn view(&self) -> Element<'_, Message, Theme, Renderer> {
|
fn view(&self) -> Element<'_, Message, Theme, Renderer> {
|
||||||
widget::column([
|
widget::column([
|
||||||
widget::scrollable(
|
widget::scrollable(
|
||||||
widget::grid(self.images.iter().map(|(path, opt_handle)| {
|
widget::grid(self.images.iter().map(|(path, maybe_image)| {
|
||||||
widget::button(
|
widget::button(widget::column([
|
||||||
widget::container(
|
widget::container(
|
||||||
widget::sensor(opt_handle.as_ref().map_or_else(
|
widget::sensor(maybe_image.handle().map_or_else(
|
||||||
|| Element::from(widget::space()),
|
|| Element::from(widget::space()),
|
||||||
|handle| widget::image(handle).into(),
|
|handle| widget::image(handle).into(),
|
||||||
))
|
))
|
||||||
|
.anticipate(2000)
|
||||||
.on_show(|_size| Message::LoadImage(path.clone()))
|
.on_show(|_size| Message::LoadImage(path.clone()))
|
||||||
.on_hide(Message::UnloadImage(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)
|
.style(widget::button::subtle)
|
||||||
.on_press(Message::OpenImage(path.clone()))
|
.on_press(Message::OpenImage(path.clone()))
|
||||||
.into()
|
.into()
|
||||||
|
|
@ -129,13 +187,10 @@ impl State {
|
||||||
)
|
)
|
||||||
.height(Length::Fill)
|
.height(Length::Fill)
|
||||||
.into(),
|
.into(),
|
||||||
self.error
|
self.error.as_ref().map_or_else(
|
||||||
.as_ref()
|
|| Element::from(widget::space()),
|
||||||
.map_or_else(
|
|error| widget::text(error).style(widget::text::danger).into(),
|
||||||
|| Element::from(widget::space()),
|
),
|
||||||
|error| widget::text(error).style(widget::text::danger).into(),
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
])
|
])
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
@ -148,6 +203,29 @@ impl State {
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn open_path(&mut self, path: impl AsRef<Path>) {
|
||||||
|
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> {
|
pub fn run() -> Result<(), impl std::error::Error> {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
mod app;
|
mod app;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
const SUPPORTED_TYPES: &[&str; 20] = &[
|
const SUPPORTED_TYPES: &[&str; 20] = &[
|
||||||
"*.png", "*.PNG", "*.jpg", "*.JPG", "*.jpeg", "*.JPEG", "*.avif", "*.jxl", "*.bmp", "*.exr",
|
"*.png", "*.PNG", "*.jpg", "*.JPG", "*.jpeg", "*.JPEG", "*.avif", "*.jxl", "*.bmp", "*.exr",
|
||||||
|
|
|
||||||
27
src/utils.rs
Normal file
27
src/utils.rs
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
use std::{ffi::OsStr, path::Path};
|
||||||
|
|
||||||
|
use image::{DynamicImage, ImageDecoder, ImageReader, ImageResult};
|
||||||
|
|
||||||
|
pub fn load_thumbnail(path: impl AsRef<Path>, max_size: u32) -> ImageResult<DynamicImage> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue