From e8761f8fcf673d2335223b94e8b606db6e0dee92 Mon Sep 17 00:00:00 2001 From: electria Date: Fri, 31 Jul 2026 11:01:51 -0700 Subject: [PATCH 01/14] 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 --- src/main.rs | 13 +++---------- src/utils.rs | 12 ++++++++++++ 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/main.rs b/src/main.rs index 3e65e8d..9ac79ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,7 +19,6 @@ enum Message { #[derive(Clone, Debug, Default)] struct State { - image: Option, image_display: Option, image_filter: widget::image::FilterMethod, @@ -168,8 +167,7 @@ impl State { if image.dimensions() < (100, 100) { self.image_filter = widget::image::FilterMethod::Nearest; } - self.image = Some(image); - self.allocate_image() + self.allocate_image(image) }) } fn pick_and_load_image(&mut self) -> Result, String> { @@ -180,16 +178,11 @@ impl State { self.error = utils::save_image(self.image.as_ref()).err(); } - fn allocate_image(&self) -> Task { - let Some(image) = self.image.as_ref() else { - eprintln!("no image to allocate"); - return Task::none(); - }; - + fn allocate_image(&self, image: RgbaImage) -> Task { widget::image::allocate(widget::image::Handle::from_rgba( image.width(), image.height(), - unsafe { std::mem::transmute::<&[u8], &'static [u8]>(image.as_bytes()) }, + image.into_vec(), )) .map(Message::ImageDisplayReady) } diff --git a/src/utils.rs b/src/utils.rs index dafd1f2..c59c7e7 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -4,6 +4,18 @@ use iced::widget; use image::{DynamicImage, ImageDecoder, ImageReader, ImageResult, RgbaImage}; 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) -> Result { _load_image(path).map_err(|e| e.to_string()) } From 26c17d5cf9bf6ba47e03b212e3cf1db2233d34c0 Mon Sep 17 00:00:00 2001 From: electria Date: Fri, 31 Jul 2026 11:14:05 -0700 Subject: [PATCH 02/14] docs: add safety note --- src/main.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main.rs b/src/main.rs index 3e65e8d..5ad2bf3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -189,6 +189,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) From 754dd6126d0937819482a89e5ec2185d36ff239d Mon Sep 17 00:00:00 2001 From: electria Date: Fri, 31 Jul 2026 11:21:37 -0700 Subject: [PATCH 03/14] fix: wrong dbus output (?) --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 40636f4..d7fa343 100644 --- a/flake.nix +++ b/flake.nix @@ -21,7 +21,7 @@ dlDeps = with pkgs; [ # libdbus, for rfd - dbus + dbus.lib # needed for both x11 and wayland libxkbcommon From 94c905db386991fd1de615d43cc4da1ae78df86e Mon Sep 17 00:00:00 2001 From: electria Date: Fri, 31 Jul 2026 14:19:53 -0700 Subject: [PATCH 04/14] build: seperate linux-specific deps --- flake.nix | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/flake.nix b/flake.nix index d7fa343..5a57299 100644 --- a/flake.nix +++ b/flake.nix @@ -19,22 +19,25 @@ cargoToml = fromTOML (builtins.readFile ./Cargo.toml); name = cargoToml.package.name; - dlDeps = with pkgs; [ - # libdbus, for rfd - dbus.lib + 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 From 9d4e24e8099a2cffa6df0c1c87c724c629f65838 Mon Sep 17 00:00:00 2001 From: electria Date: Fri, 31 Jul 2026 14:36:36 -0700 Subject: [PATCH 05/14] 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) --- src/main.rs | 95 ++++++++++++++++++++++++++-------------------------- src/utils.rs | 8 ++--- 2 files changed, 51 insertions(+), 52 deletions(-) diff --git a/src/main.rs b/src/main.rs index 5ad2bf3..c951b31 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,7 +29,7 @@ struct State { } impl State { fn new() -> (Self, Task) { - let mut state = State::default(); + let mut state = Self::default(); if let Some(path) = env::args().nth(1) { match state.load_image(path) { @@ -43,22 +43,25 @@ impl State { 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() - }); + main.push(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() + }, + )); if let Some(error) = self.error.as_ref() { main.push(widget::text(error).style(widget::text::danger).into()); @@ -84,9 +87,8 @@ 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() { @@ -97,26 +99,22 @@ impl State { 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("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, @@ -160,8 +158,7 @@ impl State { "{} {}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), image.width(), image.height(), )); @@ -210,13 +207,15 @@ 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), + ) + }, + ) } } diff --git a/src/utils.rs b/src/utils.rs index dafd1f2..cc523c4 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -5,9 +5,9 @@ use image::{DynamicImage, ImageDecoder, ImageReader, ImageResult, RgbaImage}; use rfd::FileDialog; pub fn load_image(path: impl AsRef) -> Result { - _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) -> ImageResult { +fn load_image_impl(path: impl AsRef) -> ImageResult { let mut decoder = ImageReader::open(path)? .with_guessed_format()? .into_decoder()?; @@ -48,12 +48,12 @@ pub fn save_image(image: Option<&RgbaImage>) -> Result<(), String> { if let Err(e) = image.save(&path) { return Err(e.to_string()); - }; + } Ok(()) } -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", From 18de039e77575f2b0233bb9957f940a9a8dbd5a5 Mon Sep 17 00:00:00 2001 From: electria Date: Fri, 31 Jul 2026 15:11:41 -0700 Subject: [PATCH 06/14] feat: make pickers async --- src/main.rs | 49 +++++++++++++++++++++++++++++++++---------------- src/utils.rs | 23 ++++++++--------------- 2 files changed, 41 insertions(+), 31 deletions(-) diff --git a/src/main.rs b/src/main.rs index c951b31..ba75666 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,9 @@ -use std::{borrow::Cow, env, ffi::OsStr, path::Path}; +use std::{ + borrow::Cow, + env, + ffi::OsStr, + path::{Path, PathBuf}, +}; use iced::{ Alignment, Color, Element, Length, Renderer, Subscription, Task, Theme, event, @@ -12,7 +17,9 @@ mod utils; #[derive(Clone, Debug)] enum Message { + ImagePicked(Result), ImageDisplayReady(Result), + SavePathPicked(Result), Event(iced::Event), } @@ -74,9 +81,26 @@ impl State { } fn update(&mut self, message: Message) -> Task { 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, @@ -91,13 +115,9 @@ impl State { return widget::operation::focus_next(); } - Key::Character("o") => match self.pick_and_load_image() { - Ok(task) => { - self.error = None; - return task; - } - Err(e) => self.error = Some(e), - }, + 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()), @@ -126,7 +146,11 @@ 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), @@ -169,13 +193,6 @@ impl State { self.allocate_image() }) } - fn pick_and_load_image(&mut self) -> Result, 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 { let Some(image) = self.image.as_ref() else { diff --git a/src/utils.rs b/src/utils.rs index cc523c4..fc1841d 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -2,7 +2,7 @@ use std::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) -> Result { load_image_impl(path).map_err(|e| e.to_string()) @@ -20,8 +20,8 @@ fn load_image_impl(path: impl AsRef) -> ImageResult { Ok(decoded_image.into_rgba8()) } -pub fn pick_image() -> Result { - let Some(path) = FileDialog::new() +pub async fn pick_image() -> Result { + let Some(filehandle) = AsyncFileDialog::new() .add_filter( "image", &[ @@ -30,27 +30,20 @@ pub fn pick_image() -> Result { ], ) .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 { + 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 const fn string_from_filter_type(f: widget::image::FilterMethod) -> &'static str { From 6fa0e29eae2fe13e7c140447b1def1ee87828f8c Mon Sep 17 00:00:00 2001 From: electria Date: Fri, 31 Jul 2026 17:23:29 -0700 Subject: [PATCH 07/14] feat: display file size --- Cargo.lock | 7 +++++++ Cargo.toml | 1 + src/main.rs | 9 ++++++++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 2664b09..4c05ae7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1905,6 +1905,7 @@ dependencies = [ "image", "jxl-oxide", "rfd", + "size", "zip", ] @@ -3787,6 +3788,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" diff --git a/Cargo.toml b/Cargo.toml index 9015f09..b830559 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ license = "AGPL-3.0-or-later" [dependencies] dirs = "6.0.0" rfd = "0.17.2" +size = "0.5.0" zip = "8.6.0" [dependencies.iced] diff --git a/src/main.rs b/src/main.rs index ba75666..2acbbfa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use std::{ borrow::Cow, env, ffi::OsStr, + os::unix::fs::MetadataExt, path::{Path, PathBuf}, }; @@ -11,6 +12,7 @@ use iced::{ theme, widget, window, }; use image::{EncodableLayout, RgbaImage, imageops}; +use size::Size; mod kra; mod utils; @@ -179,10 +181,15 @@ impl State { fn load_image(&mut self, path: impl AsRef) -> Result, String> { utils::load_image(&path).map(|image| { self.info = Some(format!( - "{} {}x{}", + "{} {} {}x{}", path.as_ref() .file_name() .map_or(Cow::Borrowed("[no file]"), OsStr::to_string_lossy), + path.as_ref() + .metadata() + .map_or(Cow::Borrowed("[no size]"), |m| Cow::Owned( + Size::from_bytes(m.size()).to_string() + )), image.width(), image.height(), )); From 808a8bf31d789f5af8ca505c717f5bc648bc466d Mon Sep 17 00:00:00 2001 From: electria Date: Fri, 31 Jul 2026 17:31:34 -0700 Subject: [PATCH 08/14] 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 --- src/main.rs | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/main.rs b/src/main.rs index 2acbbfa..b89efc0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,9 +50,7 @@ impl State { (state, Task::none()) } fn view(&self) -> Element<'_, Message, Theme, Renderer> { - let mut main = Vec::new(); - - main.push(self.image_display.as_ref().map_or_else( + let main = self.image_display.as_ref().map_or_else( || { widget::container(widget::text(include_str!("usage.txt"))) .height(Length::Fill) @@ -70,16 +68,27 @@ impl State { .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 { match message { From 1f909904c2479543dbb72ae70a714c2f36aac6c7 Mon Sep 17 00:00:00 2001 From: electria Date: Fri, 31 Jul 2026 17:47:12 -0700 Subject: [PATCH 09/14] fix: print error on failed to read metadata this should be very rare, so no need to display it or abort --- src/main.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index b89efc0..46cc96a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -194,11 +194,13 @@ impl State { path.as_ref() .file_name() .map_or(Cow::Borrowed("[no file]"), OsStr::to_string_lossy), - path.as_ref() - .metadata() - .map_or(Cow::Borrowed("[no size]"), |m| Cow::Owned( - Size::from_bytes(m.size()).to_string() - )), + 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(), )); From cb5e1d837a65684b437087a0319a41d67f7d024b Mon Sep 17 00:00:00 2001 From: electria Date: Fri, 31 Jul 2026 19:36:58 -0700 Subject: [PATCH 10/14] feat: image copy and paste --- Cargo.lock | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 4 +++ src/main.rs | 51 ++++++++++++++++++++++++++++++++- src/usage.txt | 3 ++ src/utils.rs | 21 +++++++++++++- 5 files changed, 155 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4c05ae7..d8f99d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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,6 +1927,7 @@ dependencies = [ name = "imagey" version = "0.1.0" dependencies = [ + "arboard", "dirs", "iced", "image", @@ -3065,6 +3093,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.52.0", +] + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -3131,6 +3169,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" @@ -4253,6 +4302,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" @@ -5116,6 +5176,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" diff --git a/Cargo.toml b/Cargo.toml index b830559..382a20f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,3 +22,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" ] diff --git a/src/main.rs b/src/main.rs index 46cc96a..2c16d88 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ use std::{ path::{Path, PathBuf}, }; +use arboard::Clipboard; use iced::{ Alignment, Color, Element, Length, Renderer, Subscription, Task, Theme, event, keyboard::{self, Key, key}, @@ -26,7 +27,7 @@ enum Message { Event(iced::Event), } -#[derive(Clone, Debug, Default)] +#[derive(Default)] struct State { image: Option, image_display: Option, @@ -35,6 +36,8 @@ struct State { error: Option, info: Option, infobar_shown: bool, + + clipboard: Option, } impl State { fn new() -> (Self, Task) { @@ -166,6 +169,52 @@ impl State { 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) => { + match clipboard.get_image().map(utils::rgbaimage_from_arboard) { + Ok(image) => { + self.error = None; + self.clipboard = Some(clipboard); + + 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()), + }; + self.clipboard = Some(clipboard); + } + Err(e) => self.error = Some(e.to_string()), + } + } + // ignore unused keys _ => {} }, diff --git a/src/usage.txt b/src/usage.txt index 1d4c6c1..9df1bc2 100644 --- a/src/usage.txt +++ b/src/usage.txt @@ -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 diff --git a/src/utils.rs b/src/utils.rs index fc1841d..474c3b3 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,7 @@ -use std::path::{Path, PathBuf}; +use std::{ + borrow::Cow, + path::{Path, PathBuf}, +}; use iced::widget; use image::{DynamicImage, ImageDecoder, ImageReader, ImageResult, RgbaImage}; @@ -52,3 +55,19 @@ pub const fn string_from_filter_type(f: widget::image::FilterMethod) -> &'static 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() +} From d3bc84769eca56e1caa4629ea7ae4582fcfb6d26 Mon Sep 17 00:00:00 2001 From: electria Date: Fri, 31 Jul 2026 20:17:25 -0700 Subject: [PATCH 11/14] refactor: improve clipboard put code style --- src/main.rs | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/main.rs b/src/main.rs index 2c16d88..013c128 100644 --- a/src/main.rs +++ b/src/main.rs @@ -194,22 +194,19 @@ impl State { None => Clipboard::new(), } { Ok(mut clipboard) => { - match clipboard.get_image().map(utils::rgbaimage_from_arboard) { - Ok(image) => { - self.error = None; - self.clipboard = Some(clipboard); + let result = clipboard.get_image().map(utils::rgbaimage_from_arboard); - 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()), - }; + 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()), } From e57c1c9322bf0620514838e7203a87eef657a13b Mon Sep 17 00:00:00 2001 From: electria Date: Sat, 1 Aug 2026 10:22:41 -0700 Subject: [PATCH 12/14] fix: jxls being rotated twice --- src/utils.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/utils.rs b/src/utils.rs index 474c3b3..ae1dbb1 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,5 +1,6 @@ use std::{ borrow::Cow, + ffi::OsStr, path::{Path, PathBuf}, }; @@ -11,7 +12,7 @@ pub fn load_image(path: impl AsRef) -> Result { load_image_impl(path).map_err(|e| e.to_string()) } fn load_image_impl(path: impl AsRef) -> ImageResult { - let mut decoder = ImageReader::open(path)? + let mut decoder = ImageReader::open(&path)? .with_guessed_format()? .into_decoder()?; @@ -19,7 +20,17 @@ fn load_image_impl(path: impl AsRef) -> ImageResult { 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()) } From db457fc4e7af44b8333c75a1e25516fdcd06529e Mon Sep 17 00:00:00 2001 From: electria Date: Mon, 3 Aug 2026 11:00:15 -0700 Subject: [PATCH 13/14] docs: add readme --- README.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..545d7ef --- /dev/null +++ b/README.md @@ -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) From 603678ba343278ee7ecf126bc28de7232b55911c Mon Sep 17 00:00:00 2001 From: electria Date: Mon, 3 Aug 2026 23:30:21 -0700 Subject: [PATCH 14/14] refactor: move kra to crate in the interest of reusing that code in a gallery app --- Cargo.lock | 13 ++++++++++++- Cargo.toml | 1 + src/kra.rs | 40 ---------------------------------------- src/main.rs | 3 +-- 4 files changed, 14 insertions(+), 43 deletions(-) delete mode 100644 src/kra.rs diff --git a/Cargo.lock b/Cargo.lock index d8f99d0..b164cb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1932,6 +1932,7 @@ dependencies = [ "iced", "image", "jxl-oxide", + "kra-image-integration", "rfd", "size", "zip", @@ -2267,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" @@ -3100,7 +3111,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 382a20f..96d9fb8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ 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" diff --git a/src/kra.rs b/src/kra.rs deleted file mode 100644 index 3642370..0000000 --- a/src/kra.rs +++ /dev/null @@ -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> { - 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>, -) -> ImageError { - ImageError::Decoding(DecodingError::new( - image::error::ImageFormatHint::PathExtension("kra".into()), - e, - )) -} diff --git a/src/main.rs b/src/main.rs index 013c128..c676e44 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,6 @@ use iced::{ use image::{EncodableLayout, RgbaImage, imageops}; use size::Size; -mod kra; mod utils; #[derive(Clone, Debug)] @@ -302,7 +301,7 @@ impl State { 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)