diff --git a/Cargo.lock b/Cargo.lock index 2664b09..b164cb9 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,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" diff --git a/Cargo.toml b/Cargo.toml index 9015f09..96d9fb8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" ] 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) diff --git a/flake.nix b/flake.nix index 40636f4..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 + 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 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 9ac79ed..c676e44 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,34 +1,46 @@ -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), ImageDisplayReady(Result), + SavePathPicked(Result), Event(iced::Event), } -#[derive(Clone, Debug, Default)] +#[derive(Default)] struct State { + image: Option, image_display: Option, image_filter: widget::image::FilterMethod, error: Option, info: Option, infobar_shown: bool, + + clipboard: Option, } 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) { @@ -40,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 { 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, @@ -83,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, @@ -127,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 _ => {} }, @@ -156,33 +235,50 @@ 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(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(), )); if image.dimensions() < (100, 100) { self.image_filter = widget::image::FilterMethod::Nearest; } - self.allocate_image(image) + self.image = Some(image); + 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 { + 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(), - image.into_vec(), + // 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) } @@ -191,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) 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 c59c7e7..ae1dbb1 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,26 +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; - -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"), - } -} +use rfd::AsyncFileDialog; 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 { - let mut decoder = ImageReader::open(path)? +fn load_image_impl(path: impl AsRef) -> ImageResult { + let mut decoder = ImageReader::open(&path)? .with_guessed_format()? .into_decoder()?; @@ -28,12 +20,22 @@ fn _load_image(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()) } -pub fn pick_image() -> Result { - let Some(path) = FileDialog::new() +pub async fn pick_image() -> Result { + let Some(filehandle) = AsyncFileDialog::new() .add_filter( "image", &[ @@ -42,32 +44,41 @@ 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 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() +}