Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
603678ba34
refactor: move kra to crate
in the interest of reusing that code in a gallery app
2026-08-03 23:30:21 -07:00
db457fc4e7
docs: add readme 2026-08-03 11:27:29 -07:00
e57c1c9322
fix: jxls being rotated twice 2026-08-01 10:22:41 -07:00
d3bc84769e
refactor: improve clipboard put code style 2026-07-31 20:17:25 -07:00
cb5e1d837a
feat: image copy and paste 2026-07-31 20:08:58 -07:00
1f909904c2
fix: print error on failed to read metadata
this should be very rare, so no need to display it or abort
2026-07-31 17:47:12 -07:00
808a8bf31d
feat: put the bars together
this made sense now that I made error messages shorter,
and figured out how to properly align things in a row.

looks good! might even consider keeping the infobar on by default
2026-07-31 17:32:52 -07:00
6fa0e29eae
feat: display file size 2026-07-31 17:23:29 -07:00
18de039e77
feat: make pickers async 2026-07-31 15:53:11 -07:00
9d4e24e809
refactor: apply clippy lints
cargo clippy --no-deps -- --deny clippy::nursery --deny clippy::pedantic
(does not quite pass due to unused `&self` in State::subscription)
I am considering ignoring events when the file picker is opened,
but that isn't a very nice solution (should just make it async atp)
2026-07-31 14:39:18 -07:00
94c905db38
build: seperate linux-specific deps 2026-07-31 14:19:53 -07:00
754dd6126d
fix: wrong dbus output (?) 2026-07-31 11:21:37 -07:00
26c17d5cf9
docs: add safety note 2026-07-31 11:14:05 -07:00
8 changed files with 371 additions and 148 deletions

96
Cargo.lock generated
View file

@ -136,6 +136,27 @@ version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
[[package]]
name = "arboard"
version = "3.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
dependencies = [
"clipboard-win",
"image",
"log",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.59.0",
"wl-clipboard-rs",
"x11rb",
]
[[package]]
name = "arg_enum_proc_macro"
version = "0.3.4"
@ -1226,6 +1247,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.9"
@ -1900,11 +1927,14 @@ dependencies = [
name = "imagey"
version = "0.1.0"
dependencies = [
"arboard",
"dirs",
"iced",
"image",
"jxl-oxide",
"kra-image-integration",
"rfd",
"size",
"zip",
]
@ -2238,6 +2268,16 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kra-image-integration"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ab1db0e6caced9f4b26057c10ea190c447a8be4fcac03afd7213e35168ad700"
dependencies = [
"image",
"zip",
]
[[package]]
name = "kurbo"
version = "0.10.4"
@ -3064,6 +3104,16 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "owned_ttf_parser"
version = "0.25.1"
@ -3130,6 +3180,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap",
]
[[package]]
name = "pin-project"
version = "1.1.13"
@ -3787,6 +3848,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "size"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b6709c7b6754dca1311b3c73e79fcce40dd414c782c66d88e8823030093b02b"
[[package]]
name = "skrifa"
version = "0.37.0"
@ -4246,6 +4313,17 @@ dependencies = [
"once_cell",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom",
"petgraph",
]
[[package]]
name = "ttf-parser"
version = "0.25.1"
@ -5109,6 +5187,24 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wl-clipboard-rs"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix 1.1.4",
"thiserror 2.0.18",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "x11-dl"
version = "2.21.0"

View file

@ -6,7 +6,9 @@ license = "AGPL-3.0-or-later"
[dependencies]
dirs = "6.0.0"
kra-image-integration = "0.1.0"
rfd = "0.17.2"
size = "0.5.0"
zip = "8.6.0"
[dependencies.iced]
@ -21,3 +23,7 @@ features = [ "avif-native" ]
version = "0.12.6"
# I really wish the image crate was named better
features = [ "image" ]
[dependencies.arboard]
version = "3.6.1"
features = [ "wayland-data-control" ]

41
README.md Normal file
View file

@ -0,0 +1,41 @@
# imagey
image viewer and maybe editor; inspired by mpv's simplicity
name is subject to change, suggestions welcome :)
## usage
```sh
# try it out!
nix run git+https://git.federated.nexus/electria/imagey
# install it imperatively
nix profile install git+https://git.federated.nexus/electria/imagey
```
keybinds are shown on startup if you didn't start it with an image
## known issues
### JXL
1. encoding is not implemented (jxl-oxide is decoding-only)
2. oreintation is incorrect in some cases
if the image has metadata oreintation AND
it's a jxl without the right extension OR
it's a jpeg with the jxl extension
this is due to the JXL decoder automatically rotating the image,
while the JPEG decoder (for instance) requires the extra step.
my workaround is to check the path of the input file,
not changing the oreintation if it has the jxl extension;
causing these caveats for cases where the extension is wrong.
### clipboard
1. large images (eg photos) don't seem to copy on linux/wayland,
(despite set_image not returning any error)

View file

@ -19,22 +19,25 @@
cargoToml = fromTOML (builtins.readFile ./Cargo.toml);
name = cargoToml.package.name;
dlDeps = with pkgs; [
# libdbus, for rfd
dbus
dlDeps =
with pkgs;
[
# needed for both x11 and wayland
libxkbcommon
libGL
# needed for both x11 and wayland
libxkbcommon
libGL
vulkan-loader
libx11
libxcursor
libxi
libxcb
]
++ lib.optionals stdenv.hostPlatform.isLinux [
# libdbus, for rfd
dbus.lib
wayland
libx11
libxcursor
libxi
libxcb
];
vulkan-loader
wayland
];
commonArgs = {
# all that's needed for artifacts and checks

View file

@ -1,40 +0,0 @@
use std::{
ffi::OsString,
io::{self, Read},
};
use image::{
ImageDecoder, ImageError, ImageReader, ImageResult,
error::DecodingError,
hooks::{self, GenericReader},
};
use zip::ZipArchive;
pub fn register() -> bool {
hooks::register_decoding_hook(OsString::from("kra"), Box::new(hook))
}
fn hook<'a>(reader: GenericReader<'a>) -> ImageResult<Box<dyn ImageDecoder + 'a>> {
let mut zip = ZipArchive::new(reader).map_err(to_image_error)?;
let mut reader = zip.by_name("mergedimage.png").map_err(to_image_error)?;
// reading it all and wrapping it with a Cursor
// is the only way I know to give it Seek
// (which is required by ImageReader)
let mut buf = Vec::new();
reader.read_to_end(&mut buf)?;
let image_reader = ImageReader::with_format(io::Cursor::new(buf), image::ImageFormat::Png);
Ok(Box::new(image_reader.into_decoder()?))
}
fn to_image_error(
e: impl Into<Box<dyn std::error::Error + std::marker::Send + Sync + 'static>>,
) -> ImageError {
ImageError::Decoding(DecodingError::new(
image::error::ImageFormatHint::PathExtension("kra".into()),
e,
))
}

View file

@ -1,23 +1,32 @@
use std::{borrow::Cow, env, ffi::OsStr, path::Path};
use std::{
borrow::Cow,
env,
ffi::OsStr,
os::unix::fs::MetadataExt,
path::{Path, PathBuf},
};
use arboard::Clipboard;
use iced::{
Alignment, Color, Element, Length, Renderer, Subscription, Task, Theme, event,
keyboard::{self, Key, key},
theme, widget, window,
};
use image::{EncodableLayout, RgbaImage, imageops};
use size::Size;
mod kra;
mod utils;
#[derive(Clone, Debug)]
enum Message {
ImagePicked(Result<PathBuf, String>),
ImageDisplayReady(Result<widget::image::Allocation, widget::image::Error>),
SavePathPicked(Result<PathBuf, String>),
Event(iced::Event),
}
#[derive(Clone, Debug, Default)]
#[derive(Default)]
struct State {
image: Option<RgbaImage>,
image_display: Option<widget::image::Allocation>,
@ -26,10 +35,12 @@ struct State {
error: Option<String>,
info: Option<String>,
infobar_shown: bool,
clipboard: Option<Clipboard>,
}
impl State {
fn new() -> (Self, Task<Message>) {
let mut state = State::default();
let mut state = Self::default();
if let Some(path) = env::args().nth(1) {
match state.load_image(path) {
@ -41,39 +52,68 @@ impl State {
(state, Task::none())
}
fn view(&self) -> Element<'_, Message, Theme, Renderer> {
let mut main = Vec::new();
main.push(if let Some(allocation) = self.image_display.as_ref() {
widget::image::viewer(allocation.handle().clone())
.filter_method(self.image_filter)
.max_scale(50.)
.min_scale(1.)
.width(Length::Fill)
.height(Length::Fill)
.into()
} else {
widget::container(widget::text(include_str!("usage.txt")))
.height(Length::Fill)
.width(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.into()
});
let main = self.image_display.as_ref().map_or_else(
|| {
widget::container(widget::text(include_str!("usage.txt")))
.height(Length::Fill)
.width(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.into()
},
|allocation| {
widget::image::viewer(allocation.handle().clone())
.filter_method(self.image_filter)
.max_scale(50.)
.min_scale(1.)
.width(Length::Fill)
.height(Length::Fill)
.into()
},
);
let mut bar = Vec::new();
if let Some(error) = self.error.as_ref() {
main.push(widget::text(error).style(widget::text::danger).into());
bar.push(
widget::container(widget::text(error).style(widget::text::danger))
.align_x(Alignment::Start)
.width(Length::Fill)
.into(),
);
}
if self.infobar_shown && self.info.is_some() {
main.push(widget::text(self.title()).into());
bar.push(
widget::container(widget::text(self.title()))
.align_x(Alignment::End)
.width(Length::Fill)
.into(),
);
}
widget::column(main).into()
widget::column([main, widget::row(bar).into()]).into()
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::ImagePicked(result) => match result.and_then(|path| self.load_image(path)) {
Err(e) => self.error = Some(e),
Ok(task) => {
self.error = None;
return task;
}
},
Message::ImageDisplayReady(result) => {
self.image_display = Some(result.unwrap());
}
Message::SavePathPicked(result) => {
self.error = result
.and_then(|path| {
self.image.as_ref().map_or_else(
|| Err("no image to save".into()),
|image| image.save(path).map_err(|e| e.to_string()),
)
})
.err();
}
Message::Event(iced::Event::Keyboard(keyboard::Event::KeyPressed {
key,
@ -84,39 +124,30 @@ impl State {
Key::Named(key::Named::Tab) => {
if modifiers.shift() {
return widget::operation::focus_previous();
} else {
return widget::operation::focus_next();
}
return widget::operation::focus_next();
}
Key::Character("o") => match self.pick_and_load_image() {
Ok(task) => {
Key::Character("o") => {
return Task::perform(utils::pick_image(), Message::ImagePicked);
}
Key::Character("r") => match self.image.as_ref() {
None => self.error = Some("no image to rotate".into()),
Some(image) => {
self.error = None;
return task;
self.image = Some(imageops::rotate90(image));
return self.allocate_image();
}
},
Key::Character("i") => match self.image.as_mut() {
None => self.error = Some("no image to invert".into()),
Some(image) => {
self.error = None;
imageops::invert(image);
return self.allocate_image();
}
Err(e) => self.error = Some(e),
},
Key::Character("r") => {
match self.image.as_ref() {
None => self.error = Some("no image to rotate".into()),
Some(image) => {
self.error = None;
self.image = Some(imageops::rotate90(image));
return self.allocate_image();
}
};
}
Key::Character("i") => {
match self.image.as_mut() {
None => self.error = Some("no image to invert".into()),
Some(image) => {
self.error = None;
imageops::invert(image);
return self.allocate_image();
}
};
}
Key::Character("f") => {
self.image_filter = match self.image_filter {
widget::image::FilterMethod::Linear => widget::image::FilterMethod::Nearest,
@ -128,11 +159,58 @@ impl State {
}
Key::Character("s") => {
self.save_image();
if self.image.is_some() {
self.error = None;
return Task::perform(utils::pick_save_path(), Message::SavePathPicked);
}
self.error = Some("no image to save".into());
}
Key::Character("q") => return window::latest().and_then(window::close),
Key::Character("y") | Key::Character("c") => {
if let Some(image) = self.image.as_ref() {
match match self.clipboard.take() {
Some(c) => Ok(c),
None => Clipboard::new(),
} {
Ok(mut clipboard) => {
self.error = clipboard
.set_image(utils::arboard_from_rgbaimage(image))
.map_err(|e| e.to_string())
.err();
self.clipboard = Some(clipboard);
}
Err(e) => self.error = Some(e.to_string()),
}
} else {
self.error = Some("no image to yank".into());
}
}
Key::Character("p") | Key::Character("v") => {
match match self.clipboard.take() {
Some(c) => Ok(c),
None => Clipboard::new(),
} {
Ok(mut clipboard) => {
let result = clipboard.get_image().map(utils::rgbaimage_from_arboard);
self.error = result.as_ref().map_err(|e| e.to_string()).err();
self.clipboard = Some(clipboard);
if let Ok(image) = result {
self.info = Some(format!("{}x{}", image.width(), image.height(),));
if image.dimensions() < (100, 100) {
self.image_filter = widget::image::FilterMethod::Nearest;
}
self.image = Some(image);
return self.allocate_image();
}
}
Err(e) => self.error = Some(e.to_string()),
}
}
// ignore unused keys
_ => {}
},
@ -157,11 +235,17 @@ impl State {
fn load_image(&mut self, path: impl AsRef<Path>) -> Result<Task<Message>, String> {
utils::load_image(&path).map(|image| {
self.info = Some(format!(
"{} {}x{}",
"{} {} {}x{}",
path.as_ref()
.file_name()
.map(OsStr::to_string_lossy)
.unwrap_or(Cow::Borrowed("[no file]")),
.map_or(Cow::Borrowed("[no file]"), OsStr::to_string_lossy),
path.as_ref().metadata().map_or_else(
|e| {
eprintln!("failed to read metadata: {e}");
Cow::Borrowed("[no size]")
},
|m| Cow::Owned(Size::from_bytes(m.size()).to_string())
),
image.width(),
image.height(),
));
@ -172,13 +256,6 @@ impl State {
self.allocate_image()
})
}
fn pick_and_load_image(&mut self) -> Result<Task<Message>, String> {
utils::pick_image().and_then(|path| self.load_image(path))
}
fn save_image(&mut self) {
self.error = utils::save_image(self.image.as_ref()).err();
}
fn allocate_image(&self) -> Task<Message> {
let Some(image) = self.image.as_ref() else {
@ -189,6 +266,18 @@ impl State {
widget::image::allocate(widget::image::Handle::from_rgba(
image.width(),
image.height(),
// SAFETY: this is a sort of race condition;
// will cause panics with large enough images,
// when they are edited consecutively.
//
// this can be reproduced by holding down 'i' with a 10k by 10k px image,
// which will invert the colors rapidly and eventually crash the app.
//
// however, that is not such a problem in this case,
// and I don't know a different way of doing this that doesn't copy
// (performance loss, which is can be pretty big)
// or likely cause flickering instead in such cases
// (like using Handle over Allocation to immediately drop the last one)
unsafe { std::mem::transmute::<&[u8], &'static [u8]>(image.as_bytes()) },
))
.map(Message::ImageDisplayReady)
@ -198,19 +287,21 @@ impl State {
event::listen().map(Message::Event)
}
fn title(&self) -> String {
match self.info.as_ref() {
Some(info) => format!(
"imagey {info} {}",
utils::string_from_filter_type(self.image_filter),
),
None => "imagey".into(),
}
self.info.as_ref().map_or_else(
|| "imagey".into(),
|info| {
format!(
"imagey {info} {}",
utils::string_from_filter_type(self.image_filter),
)
},
)
}
}
fn main() -> Result<(), iced::Error> {
jxl_oxide::integration::register_image_decoding_hook();
kra::register();
kra_image_integration::register();
iced::application(State::new, State::update, State::view)
.subscription(State::subscription)

View file

@ -5,3 +5,6 @@
'b' to toggle the bar
's' to save the image
'q' to quit
'y' or 'c' to yank
'p' or 'v' to put

View file

@ -1,14 +1,18 @@
use std::path::{Path, PathBuf};
use std::{
borrow::Cow,
ffi::OsStr,
path::{Path, PathBuf},
};
use iced::widget;
use image::{DynamicImage, ImageDecoder, ImageReader, ImageResult, RgbaImage};
use rfd::FileDialog;
use rfd::AsyncFileDialog;
pub fn load_image(path: impl AsRef<Path>) -> Result<RgbaImage, String> {
_load_image(path).map_err(|e| e.to_string())
load_image_impl(path).map_err(|e| e.to_string())
}
fn _load_image(path: impl AsRef<Path>) -> ImageResult<RgbaImage> {
let mut decoder = ImageReader::open(path)?
fn load_image_impl(path: impl AsRef<Path>) -> ImageResult<RgbaImage> {
let mut decoder = ImageReader::open(&path)?
.with_guessed_format()?
.into_decoder()?;
@ -16,12 +20,22 @@ fn _load_image(path: impl AsRef<Path>) -> ImageResult<RgbaImage> {
let mut decoded_image = DynamicImage::from_decoder(decoder)?;
decoded_image.apply_orientation(oreintation);
// 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.into_rgba8())
}
pub fn pick_image() -> Result<PathBuf, String> {
let Some(path) = FileDialog::new()
pub async fn pick_image() -> Result<PathBuf, String> {
let Some(filehandle) = AsyncFileDialog::new()
.add_filter(
"image",
&[
@ -30,32 +44,41 @@ pub fn pick_image() -> Result<PathBuf, String> {
],
)
.pick_file()
.await
else {
return Err("no path to open provided".into());
};
Ok(path)
Ok(filehandle.path().to_owned())
}
pub fn save_image(image: Option<&RgbaImage>) -> Result<(), String> {
let Some(image) = image else {
return Err("no image to save".into());
};
let Some(path) = FileDialog::new().save_file() else {
pub async fn pick_save_path() -> Result<PathBuf, String> {
let Some(filehandle) = AsyncFileDialog::new().save_file().await else {
return Err("no path to save provided".into());
};
if let Err(e) = image.save(&path) {
return Err(e.to_string());
};
Ok(())
Ok(filehandle.path().to_owned())
}
pub fn string_from_filter_type(f: widget::image::FilterMethod) -> &'static str {
pub const fn string_from_filter_type(f: widget::image::FilterMethod) -> &'static str {
match f {
widget::image::FilterMethod::Linear => "bilinear",
widget::image::FilterMethod::Nearest => "nearest neighbor",
}
}
pub fn arboard_from_rgbaimage<'a>(image: &'a RgbaImage) -> arboard::ImageData<'a> {
arboard::ImageData {
width: image.width() as usize,
height: image.height() as usize,
bytes: Cow::Borrowed(image.as_ref()),
}
}
pub fn rgbaimage_from_arboard(image: arboard::ImageData) -> RgbaImage {
RgbaImage::from_raw(
image.width as u32,
image.height as u32,
image.bytes.into_owned(),
)
.unwrap()
}