diff --git a/Cargo.lock b/Cargo.lock index b164cb9..2664b09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -136,27 +136,6 @@ 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" @@ -1247,12 +1226,6 @@ 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" @@ -1927,14 +1900,11 @@ dependencies = [ name = "imagey" version = "0.1.0" dependencies = [ - "arboard", "dirs", "iced", "image", "jxl-oxide", - "kra-image-integration", "rfd", - "size", "zip", ] @@ -2268,16 +2238,6 @@ 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" @@ -3104,16 +3064,6 @@ 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" @@ -3180,17 +3130,6 @@ 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" @@ -3848,12 +3787,6 @@ 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" @@ -4313,17 +4246,6 @@ 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" @@ -5187,24 +5109,6 @@ 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 96d9fb8..9015f09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,9 +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" [dependencies.iced] @@ -23,7 +21,3 @@ 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 deleted file mode 100644 index 545d7ef..0000000 --- a/README.md +++ /dev/null @@ -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) diff --git a/flake.nix b/flake.nix index 5a57299..40636f4 100644 --- a/flake.nix +++ b/flake.nix @@ -19,25 +19,22 @@ cargoToml = fromTOML (builtins.readFile ./Cargo.toml); name = cargoToml.package.name; - dlDeps = - with pkgs; - [ - # needed for both x11 and wayland - libxkbcommon - libGL + dlDeps = with pkgs; [ + # libdbus, for rfd + dbus - libx11 - libxcursor - libxi - libxcb - ] - ++ lib.optionals stdenv.hostPlatform.isLinux [ - # libdbus, for rfd - dbus.lib + # needed for both x11 and wayland + libxkbcommon + libGL + vulkan-loader - vulkan-loader - wayland - ]; + wayland + + libx11 + libxcursor + libxi + libxcb + ]; commonArgs = { # all that's needed for artifacts and checks diff --git a/src/kra.rs b/src/kra.rs new file mode 100644 index 0000000..3642370 --- /dev/null +++ b/src/kra.rs @@ -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> { + 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 c676e44..9ac79ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,46 +1,34 @@ -use std::{ - borrow::Cow, - env, - ffi::OsStr, - os::unix::fs::MetadataExt, - path::{Path, PathBuf}, -}; +use std::{borrow::Cow, env, ffi::OsStr, path::Path}; -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(Default)] +#[derive(Clone, Debug, 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 = Self::default(); + let mut state = State::default(); if let Some(path) = env::args().nth(1) { match state.load_image(path) { @@ -52,68 +40,39 @@ impl State { (state, Task::none()) } fn view(&self) -> Element<'_, Message, Theme, Renderer> { - 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 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 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) - .into(), - ); + main.push(widget::text(error).style(widget::text::danger).into()); } if self.infobar_shown && self.info.is_some() { - bar.push( - widget::container(widget::text(self.title())) - .align_x(Alignment::End) - .width(Length::Fill) - .into(), - ); + main.push(widget::text(self.title()).into()); } - widget::column([main, widget::row(bar).into()]).into() + widget::column(main).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, @@ -124,30 +83,39 @@ 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") => { - return Task::perform(utils::pick_image(), Message::ImagePicked); - } + Key::Character("o") => match self.pick_and_load_image() { + Ok(task) => { + self.error = None; + return task; + } + 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, @@ -159,58 +127,11 @@ impl State { } Key::Character("s") => { - 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()); + self.save_image(); } 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 _ => {} }, @@ -235,50 +156,33 @@ 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_else( - |e| { - eprintln!("failed to read metadata: {e}"); - Cow::Borrowed("[no size]") - }, - |m| Cow::Owned(Size::from_bytes(m.size()).to_string()) - ), + .map(OsStr::to_string_lossy) + .unwrap_or(Cow::Borrowed("[no file]")), image.width(), image.height(), )); 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> { + utils::pick_image().and_then(|path| self.load_image(path)) + } - fn allocate_image(&self) -> Task { - let Some(image) = self.image.as_ref() else { - eprintln!("no image to allocate"); - return Task::none(); - }; + fn save_image(&mut self) { + self.error = utils::save_image(self.image.as_ref()).err(); + } + fn allocate_image(&self, image: RgbaImage) -> Task { 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()) }, + image.into_vec(), )) .map(Message::ImageDisplayReady) } @@ -287,21 +191,19 @@ impl State { event::listen().map(Message::Event) } fn title(&self) -> String { - self.info.as_ref().map_or_else( - || "imagey".into(), - |info| { - format!( - "imagey {info} {}", - utils::string_from_filter_type(self.image_filter), - ) - }, - ) + match self.info.as_ref() { + Some(info) => format!( + "imagey {info} {}", + utils::string_from_filter_type(self.image_filter), + ), + None => "imagey".into(), + } } } fn main() -> Result<(), iced::Error> { jxl_oxide::integration::register_image_decoding_hook(); - kra_image_integration::register(); + kra::register(); iced::application(State::new, State::update, State::view) .subscription(State::subscription) diff --git a/src/usage.txt b/src/usage.txt index 9df1bc2..1d4c6c1 100644 --- a/src/usage.txt +++ b/src/usage.txt @@ -5,6 +5,3 @@ '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 ae1dbb1..c59c7e7 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,18 +1,26 @@ -use std::{ - borrow::Cow, - ffi::OsStr, - path::{Path, PathBuf}, -}; +use std::path::{Path, PathBuf}; use iced::widget; 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) -> Result { - 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) -> ImageResult { - let mut decoder = ImageReader::open(&path)? +fn _load_image(path: impl AsRef) -> ImageResult { + let mut decoder = ImageReader::open(path)? .with_guessed_format()? .into_decoder()?; @@ -20,22 +28,12 @@ fn load_image_impl(path: impl AsRef) -> ImageResult { 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()) } -pub async fn pick_image() -> Result { - let Some(filehandle) = AsyncFileDialog::new() +pub fn pick_image() -> Result { + let Some(path) = FileDialog::new() .add_filter( "image", &[ @@ -44,41 +42,32 @@ pub async fn pick_image() -> Result { ], ) .pick_file() - .await else { return Err("no path to open provided".into()); }; - Ok(filehandle.path().to_owned()) + Ok(path) } -pub async fn pick_save_path() -> Result { - let Some(filehandle) = AsyncFileDialog::new().save_file().await else { +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 { 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 { 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() -}