Compare commits

..
Author SHA1 Message Date
e8761f8fcf
try to give ownership of the image to the Handle
this doesn't work without copying,
because RgbaImage is always an owned type

as such, it is likely not worth it
2026-07-31 11:01:51 -07:00
8 changed files with 635 additions and 854 deletions

949
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -6,14 +6,11 @@ license = "AGPL-3.0-or-later"
[dependencies] [dependencies]
dirs = "6.0.0" dirs = "6.0.0"
misc-image-integration = "0.1.0"
resvg = "0.48.1"
rfd = "0.17.2" rfd = "0.17.2"
size = "0.5.0"
zip = "8.6.0" zip = "8.6.0"
[dependencies.iced] [dependencies.iced]
version = "0.15.0-dev" version = "0.14.0"
features = [ "image" ] features = [ "image" ]
[dependencies.image] [dependencies.image]
@ -24,6 +21,3 @@ features = [ "avif-native" ]
version = "0.12.6" version = "0.12.6"
# I really wish the image crate was named better # I really wish the image crate was named better
features = [ "image" ] features = [ "image" ]
[patch.crates-io]
iced.git = "https://github.com/iced-rs/iced"

View file

@ -1,41 +0,0 @@
# 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,24 +19,21 @@
cargoToml = fromTOML (builtins.readFile ./Cargo.toml); cargoToml = fromTOML (builtins.readFile ./Cargo.toml);
name = cargoToml.package.name; name = cargoToml.package.name;
dlDeps = dlDeps = with pkgs; [
with pkgs; # libdbus, for rfd
[ dbus
# needed for both x11 and wayland # needed for both x11 and wayland
libxkbcommon libxkbcommon
libGL libGL
vulkan-loader
wayland
libx11 libx11
libxcursor libxcursor
libxi libxi
libxcb libxcb
]
++ lib.optionals stdenv.hostPlatform.isLinux [
# libdbus, for rfd
dbus.lib
vulkan-loader
wayland
]; ];
commonArgs = { commonArgs = {
@ -75,7 +72,6 @@
"image/bmp" "image/bmp"
"image/vnd.microsoft.icon" # .ico "image/vnd.microsoft.icon" # .ico
"application/x-krita" "application/x-krita"
"image/svg+xml"
]; ];
}; };
in in

40
src/kra.rs Normal file
View file

@ -0,0 +1,40 @@
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,63 +1,38 @@
use std::{ use std::{borrow::Cow, env, ffi::OsStr, path::Path};
borrow::Cow,
env,
ffi::OsStr,
os::unix::fs::MetadataExt,
path::{Path, PathBuf},
};
use iced::{ use iced::{
Alignment, Color, Element, Length, Renderer, Subscription, Task, Theme, clipboard, event, Alignment, Color, Element, Length, Renderer, Subscription, Task, Theme, event,
keyboard::{self, Key, key}, keyboard::{self, Key, key},
theme, widget, window, theme, widget, window,
}; };
use image::{EncodableLayout, RgbaImage, imageops}; use image::{EncodableLayout, RgbaImage, imageops};
use size::Size;
mod kra;
mod utils; mod utils;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
enum Message { enum Message {
Open,
Rotate,
Invert,
Filter,
Bar,
Save,
Quit,
Yank,
Put,
ImageYanked(Result<(), clipboard::Error>),
ImagePut(Result<clipboard::Image, clipboard::Error>),
FocusNext,
FocusPrevious,
ImagePicked(Result<PathBuf, Cow<'static, str>>),
ImageDisplayReady(Result<widget::image::Allocation, widget::image::Error>), ImageDisplayReady(Result<widget::image::Allocation, widget::image::Error>),
SavePathPicked(Result<PathBuf, Cow<'static, str>>),
FileDropped(PathBuf), Event(iced::Event),
} }
#[derive(Default)] #[derive(Clone, Debug, Default)]
struct State { struct State {
image: Option<RgbaImage>,
image_display: Option<widget::image::Allocation>, image_display: Option<widget::image::Allocation>,
image_filter: widget::image::FilterMethod, image_filter: widget::image::FilterMethod,
error: Option<Cow<'static, str>>, error: Option<String>,
info: Option<String>, info: Option<String>,
infobar_shown: bool, infobar_shown: bool,
} }
impl State { impl State {
fn new() -> (Self, Task<Message>) { fn new() -> (Self, Task<Message>) {
let mut state = Self::default(); let mut state = State::default();
if let Some(path) = env::args().nth(1) { if let Some(path) = env::args().nth(1) {
match state.load_image(path) { match state.load_image(path) {
Err(e) => state.error = Some(e.into()), Err(e) => state.error = Some(e),
Ok(task) => return (state, task), Ok(task) => return (state, task),
} }
} }
@ -65,16 +40,9 @@ impl State {
(state, Task::none()) (state, Task::none())
} }
fn view(&self) -> Element<'_, Message, Theme, Renderer> { fn view(&self) -> Element<'_, Message, Theme, Renderer> {
let main = self.image_display.as_ref().map_or_else( let mut main = Vec::new();
|| {
widget::container(widget::text(include_str!("usage.txt"))) main.push(if let Some(allocation) = self.image_display.as_ref() {
.height(Length::Fill)
.width(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.into()
},
|allocation| {
widget::image::viewer(allocation.handle().clone()) widget::image::viewer(allocation.handle().clone())
.filter_method(self.image_filter) .filter_method(self.image_filter)
.max_scale(50.) .max_scale(50.)
@ -82,131 +50,104 @@ impl State {
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.into() .into()
}, } else {
); widget::container(widget::text(include_str!("usage.txt")))
.height(Length::Fill)
let mut bar = Vec::new();
if let Some(error) = self.error.as_ref() {
bar.push(
widget::container(widget::text(error).style(widget::text::danger))
.align_x(Alignment::Start)
.width(Length::Fill) .width(Length::Fill)
.into(), .align_x(Alignment::Center)
); .align_y(Alignment::Center)
.into()
});
if let Some(error) = self.error.as_ref() {
main.push(widget::text(error).style(widget::text::danger).into());
} }
if self.infobar_shown && self.info.is_some() { if self.infobar_shown && self.info.is_some() {
bar.push( main.push(widget::text(self.title()).into());
widget::container(widget::text(self.title()))
.align_x(Alignment::End)
.width(Length::Fill)
.into(),
);
} }
widget::column([main, widget::row(bar).into()]).into() widget::column(main).into()
} }
fn update(&mut self, message: Message) -> Task<Message> { fn update(&mut self, message: Message) -> Task<Message> {
match message { match message {
Message::ImagePicked(result) => { Message::ImageDisplayReady(result) => {
match result.and_then(|path| self.load_image(path).map_err(Cow::from)) { self.image_display = Some(result.unwrap());
Err(e) => self.error = Some(e.into()), }
Message::Event(iced::Event::Keyboard(keyboard::Event::KeyPressed {
key,
modifiers,
..
})) => match key.as_ref() {
// input field cycling
Key::Named(key::Named::Tab) => {
if modifiers.shift() {
return widget::operation::focus_previous();
} else {
return widget::operation::focus_next();
}
}
Key::Character("o") => match self.pick_and_load_image() {
Ok(task) => { Ok(task) => {
self.error = None; self.error = None;
return task; return task;
} }
} Err(e) => self.error = Some(e),
} },
Message::ImageDisplayReady(result) => {
self.image_display = Some(result.unwrap());
}
Message::SavePathPicked(result) => {
self.error = result
.map_err(Cow::from)
.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().into()),
)
})
.err();
}
Message::FocusNext => return widget::operation::focus_next(), Key::Character("r") => {
Message::FocusPrevious => return widget::operation::focus_previous(), match self.image.as_ref() {
Message::Open => {
return Task::perform(utils::pick_image(), Message::ImagePicked);
}
Message::Rotate => match self.image.as_ref() {
None => self.error = Some("no image to rotate".into()), None => self.error = Some("no image to rotate".into()),
Some(image) => { Some(image) => {
self.error = None; self.error = None;
self.image = Some(imageops::rotate90(image)); self.image = Some(imageops::rotate90(image));
return self.allocate_image(); return self.allocate_image();
} }
}, };
Message::Invert => match self.image.as_mut() { }
Key::Character("i") => {
match self.image.as_mut() {
None => self.error = Some("no image to invert".into()), None => self.error = Some("no image to invert".into()),
Some(image) => { Some(image) => {
self.error = None; self.error = None;
imageops::invert(image); imageops::invert(image);
return self.allocate_image(); return self.allocate_image();
} }
}, };
Message::Filter => { }
Key::Character("f") => {
self.image_filter = match self.image_filter { self.image_filter = match self.image_filter {
widget::image::FilterMethod::Linear => widget::image::FilterMethod::Nearest, widget::image::FilterMethod::Linear => widget::image::FilterMethod::Nearest,
widget::image::FilterMethod::Nearest => widget::image::FilterMethod::Linear, widget::image::FilterMethod::Nearest => widget::image::FilterMethod::Linear,
} }
} }
Message::Bar => { Key::Character("b") => {
self.infobar_shown = !self.infobar_shown; self.infobar_shown = !self.infobar_shown;
} }
Message::Save => { Key::Character("s") => {
if self.image.is_some() { self.save_image();
self.error = None;
return Task::perform(utils::pick_save_path(), Message::SavePathPicked);
}
self.error = Some("no image to save".into());
} }
Message::Quit => return window::latest().and_then(window::close), Key::Character("q") => return window::latest().and_then(window::close),
Message::Yank => { // ignore unused keys
if let Some(image) = self.image.as_ref() { _ => {}
return clipboard::write(utils::clipboard_image_from_rgbaimage(&image)) },
.map(Message::ImageYanked);
} else {
self.error = Some("no image to yank".into());
}
}
Message::Put => return clipboard::read_image().map(Message::ImagePut),
Message::ImageYanked(result) => { Message::Event(iced::Event::Window(window::Event::FileDropped(file))) => {
self.error = result.err().map(|e| format!("{e:?}").into()); match self.load_image(file) {
}
Message::ImagePut(result) => {
self.error = result.as_ref().err().map(|e| format!("{e:?}").into());
if let Ok(image) = result {
self.info = Some(format!("{}x{}", image.size.width, image.size.height,));
if (image.size.width, image.size.height) < (100, 100) {
self.image_filter = widget::image::FilterMethod::Nearest;
}
self.image = Some(utils::rgbaimage_from_clipboard_image(image));
return self.allocate_image();
}
}
Message::FileDropped(path) => match self.load_image(path) {
Ok(task) => { Ok(task) => {
self.error = None; self.error = None;
return task; return task;
} }
Err(e) => self.error = Some(e.into()), Err(e) => self.error = Some(e),
}, }
}
// ignore unused events
Message::Event(_) => {}
} }
Task::none() Task::none()
@ -215,110 +156,64 @@ impl State {
fn load_image(&mut self, path: impl AsRef<Path>) -> Result<Task<Message>, String> { fn load_image(&mut self, path: impl AsRef<Path>) -> Result<Task<Message>, String> {
utils::load_image(&path).map(|image| { utils::load_image(&path).map(|image| {
self.info = Some(format!( self.info = Some(format!(
"{} {} {}x{}", "{} {}x{}",
path.as_ref() path.as_ref()
.file_name() .file_name()
.map_or(Cow::Borrowed("[no file]"), OsStr::to_string_lossy), .map(OsStr::to_string_lossy)
path.as_ref().metadata().map_or_else( .unwrap_or(Cow::Borrowed("[no file]")),
|e| {
eprintln!("failed to read metadata: {e}");
Cow::Borrowed("[no size]")
},
|m| Cow::Owned(Size::from_bytes(m.size()).to_string())
),
image.width(), image.width(),
image.height(), image.height(),
)); ));
if image.dimensions() < (100, 100) { if image.dimensions() < (100, 100) {
self.image_filter = widget::image::FilterMethod::Nearest; self.image_filter = widget::image::FilterMethod::Nearest;
} }
self.image = Some(image); self.allocate_image(image)
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 allocate_image(&self) -> Task<Message> { fn save_image(&mut self) {
let Some(image) = self.image.as_ref() else { self.error = utils::save_image(self.image.as_ref()).err();
eprintln!("no image to allocate"); }
return Task::none();
};
fn allocate_image(&self, image: RgbaImage) -> Task<Message> {
widget::image::allocate(widget::image::Handle::from_rgba( widget::image::allocate(widget::image::Handle::from_rgba(
image.width(), image.width(),
image.height(), image.height(),
// SAFETY: this is a sort of race condition; image.into_vec(),
// 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) .map(Message::ImageDisplayReady)
} }
fn subscription(&self) -> Subscription<Message> { fn subscription(&self) -> Subscription<Message> {
event::listen().filter_map(|event| match event { event::listen().map(Message::Event)
iced::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => {
match key.as_ref() {
Key::Named(key::Named::Tab) => Some(if modifiers.shift() {
Message::FocusPrevious
} else {
Message::FocusNext
}),
Key::Character("o") => Some(Message::Open),
Key::Character("r") => Some(Message::Rotate),
Key::Character("i") => Some(Message::Invert),
Key::Character("f") => Some(Message::Filter),
Key::Character("b") => Some(Message::Bar),
Key::Character("s") => Some(Message::Save),
Key::Character("q") => Some(Message::Quit),
Key::Character("y") | Key::Character("c") => Some(Message::Yank),
Key::Character("p") | Key::Character("v") => Some(Message::Put),
_ => None,
}
}
iced::Event::Window(window::Event::FileDropped(path)) => {
Some(Message::FileDropped(path))
}
_ => None,
})
} }
fn title(&self) -> String { fn title(&self) -> String {
self.info.as_ref().map_or_else( match self.info.as_ref() {
|| "imagey".into(), Some(info) => format!(
|info| {
format!(
"imagey {info} {}", "imagey {info} {}",
utils::string_from_filter_type(self.image_filter), utils::string_from_filter_type(self.image_filter),
) ),
}, None => "imagey".into(),
) }
} }
} }
fn main() -> Result<(), iced::Error> { fn main() -> Result<(), iced::Error> {
jxl_oxide::integration::register_image_decoding_hook(); jxl_oxide::integration::register_image_decoding_hook();
misc_image_integration::register(); kra::register();
iced::application(State::new, State::update, State::view) iced::application(State::new, State::update, State::view)
.subscription(State::subscription) .subscription(State::subscription)
.title(State::title) .title(State::title)
.theme(Theme::custom( .theme(Theme::custom(
"custom", "custom",
theme::palette::Seed { theme::Palette {
background: Color::BLACK, background: Color::BLACK,
text: Color::WHITE, text: Color::WHITE,
..theme::palette::Seed::DARK ..theme::Palette::DARK
}, },
)) ))
.run() .run()

View file

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

View file

@ -1,18 +1,26 @@
use std::{ use std::path::{Path, PathBuf};
borrow::Cow,
ffi::OsStr,
path::{Path, PathBuf},
};
use iced::{clipboard, widget}; use iced::widget;
use image::{DynamicImage, EncodableLayout, ImageDecoder, ImageReader, ImageResult, RgbaImage}; use image::{DynamicImage, ImageDecoder, ImageReader, ImageResult, RgbaImage};
use rfd::AsyncFileDialog; use rfd::FileDialog;
pub fn image_from_handle(handle: widget::image::Handle) -> RgbaImage {
match handle {
widget::image::Handle::Rgba {
id,
width,
height,
pixels,
} => RgbaImage::from_raw(width, height, pixels.as_ref()).unwrap(),
_ => panic!("handle should always hold rgba data"),
}
}
pub fn load_image(path: impl AsRef<Path>) -> Result<RgbaImage, String> { pub fn load_image(path: impl AsRef<Path>) -> Result<RgbaImage, String> {
load_image_impl(path).map_err(|e| e.to_string()) _load_image(path).map_err(|e| e.to_string())
} }
fn load_image_impl(path: impl AsRef<Path>) -> ImageResult<RgbaImage> { fn _load_image(path: impl AsRef<Path>) -> ImageResult<RgbaImage> {
let mut decoder = ImageReader::open(&path)? let mut decoder = ImageReader::open(path)?
.with_guessed_format()? .with_guessed_format()?
.into_decoder()?; .into_decoder()?;
@ -20,65 +28,46 @@ fn load_image_impl(path: impl AsRef<Path>) -> ImageResult<RgbaImage> {
let mut decoded_image = DynamicImage::from_decoder(decoder)?; let mut decoded_image = DynamicImage::from_decoder(decoder)?;
// 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); decoded_image.apply_orientation(oreintation);
}
Ok(decoded_image.into_rgba8()) Ok(decoded_image.into_rgba8())
} }
pub async fn pick_image() -> Result<PathBuf, Cow<'static, str>> { pub fn pick_image() -> Result<PathBuf, String> {
let Some(filehandle) = AsyncFileDialog::new() let Some(path) = FileDialog::new()
.add_filter( .add_filter(
"image", "image",
&[ &[
"png", "PNG", "jpg", "JPG", "jpeg", "JPEG", "avif", "jxl", "bmp", "exr", "ff", "png", "PNG", "jpg", "JPG", "jpeg", "JPEG", "avif", "jxl", "bmp", "exr", "ff",
"gif", "hdr", "ico", "pnm", "qoi", "tga", "tiff", "webp", "kra", "svg", "svgz", "gif", "hdr", "ico", "pnm", "qoi", "tga", "tiff", "webp", "kra",
], ],
) )
.pick_file() .pick_file()
.await
else { else {
return Err("no path to open provided".into()); return Err("no path to open provided".into());
}; };
Ok(filehandle.path().to_owned()) Ok(path)
} }
pub async fn pick_save_path() -> Result<PathBuf, Cow<'static, str>> { pub fn save_image(image: Option<&RgbaImage>) -> Result<(), String> {
let Some(filehandle) = AsyncFileDialog::new().save_file().await else { let Some(image) = image else {
return Err("no image to save".into());
};
let Some(path) = FileDialog::new().save_file() else {
return Err("no path to save provided".into()); return Err("no path to save provided".into());
}; };
Ok(filehandle.path().to_owned()) if let Err(e) = image.save(&path) {
return Err(e.to_string());
};
Ok(())
} }
pub const fn string_from_filter_type(f: widget::image::FilterMethod) -> &'static str { pub fn string_from_filter_type(f: widget::image::FilterMethod) -> &'static str {
match f { match f {
widget::image::FilterMethod::Linear => "bilinear", widget::image::FilterMethod::Linear => "bilinear",
widget::image::FilterMethod::Nearest => "nearest neighbor", widget::image::FilterMethod::Nearest => "nearest neighbor",
} }
} }
pub fn clipboard_image_from_rgbaimage(image: &RgbaImage) -> clipboard::Image {
clipboard::Image {
size: (image.width(), image.height()).into(),
// SAFETY: this is also probably a race condition,
// where if the user switches the image before it's done,
// then something will die.
//
// however, especially with larger images,
// copying them is just too expensive.
rgba: unsafe { std::mem::transmute::<&[u8], &'static [u8]>(image.as_bytes()) }.into(),
}
}
pub fn rgbaimage_from_clipboard_image(image: clipboard::Image) -> RgbaImage {
RgbaImage::from_raw(image.size.width, image.size.height, image.rgba.into()).unwrap()
}