diff --git a/Cargo.lock b/Cargo.lock index bc4a4d2..0c6791e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -812,6 +812,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.13.0", + "block2 0.6.2", + "libc", "objc2 0.6.4", ] @@ -1647,6 +1649,7 @@ dependencies = [ "dirs", "iced", "image", + "rfd", ] [[package]] @@ -2719,6 +2722,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "portable-atomic" version = "1.13.1" @@ -3019,6 +3028,33 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "percent-encoding", + "pollster", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "web-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rgb" version = "0.8.53" diff --git a/Cargo.toml b/Cargo.toml index 5e76228..668d6f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,3 +7,4 @@ edition = "2024" iced = { version = "0.14.0", features = [ "image" ] } dirs = "6.0.0" image = "0.25.10" +rfd = "0.17.2" diff --git a/flake.nix b/flake.nix index 35174c5..b86a384 100644 --- a/flake.nix +++ b/flake.nix @@ -40,7 +40,7 @@ buildInputs = with pkgs; [ ]; - src = craneLib.cleanCargoSource ./.; + src = ./.; }; LD_LIBRARY_PATH = lib.makeLibraryPath dlDeps; @@ -58,6 +58,16 @@ desktopName = name; icon = name; exec = name; + mimeTypes = [ + "image/png" + "image/jpeg" + "image/gif" + "image/webp" + "image/avif" + "image/tiff" + "image/bmp" + "image/vnd.microsoft.icon" # .ico + ]; }; in craneLib.buildPackage ( @@ -65,12 +75,16 @@ // { inherit cargoArtifacts; - nativeBuildInputs = commonArgs.nativeBuildInputs ++ [ - pkgs.autoPatchelfHook - ]; + nativeBuildInputs = + commonArgs.nativeBuildInputs + ++ [ + pkgs.autoPatchelfHook + ] + ++ lib.optional pkgs.stdenv.hostPlatform.isLinux pkgs.makeBinaryWrapper; buildInputs = commonArgs.buildInputs ++ [ pkgs.libgcc + pkgs.zenity ]; runtimeDependencies = dlDeps; @@ -80,6 +94,10 @@ postFixup = '' mkdir -p "$out/share/applications" ln -s "${desktopItem}"/share/applications/* "$out/share/applications/" + '' + + lib.optionalString pkgs.stdenv.hostPlatform.isLinux /* sh */ '' + wrapProgram $out/bin/${name} --inherit-argv0 \ + --prefix PATH : ${lib.makeBinPath [ pkgs.zenity ]} ''; } ); diff --git a/src/main.rs b/src/main.rs index 2e6973d..663d849 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,12 @@ -use std::env; +use std::{env, ffi::OsStr, fs}; use iced::{ - Color, Element, Renderer, Subscription, Task, Theme, color, event, + Alignment, Color, Element, Length, Renderer, Subscription, Task, Theme, color, event, keyboard::{self, Key, key}, theme, widget, window, }; -use image::{EncodableLayout, ImageReader, Pixel, Rgba, RgbaImage, imageops}; +use image::{EncodableLayout, ImageReader, RgbaImage, codecs::webp::WebPEncoder, imageops}; +use rfd::FileDialog; #[derive(Clone, Debug)] enum Message { @@ -16,43 +17,53 @@ enum Message { #[derive(Clone, Debug)] struct State { - image: RgbaImage, + image: Option, image_display: Option, + + error: Option, } impl State { fn new() -> (Self, Task) { - let image = if let Some(path) = env::args().nth(1) { - ImageReader::open(path) - .unwrap() - .decode() - .unwrap() - .into_rgba8() - } else { - let mut image = RgbaImage::new(100, 100); - - imageops::horizontal_gradient( - &mut image, - Rgba::from_slice(&[0, 0, 0, 0]), - Rgba::from_slice(&[255, 255, 255, 255]), - ); - - image - }; - let state = State { image_display: None, - image, + image: env::args().nth(1).and_then(|path| { + Some( + ImageReader::open(path) + .unwrap() + .decode() + .unwrap() + .into_rgba8(), + ) + }), + + error: None, }; let allocate_image = state.allocate_image(); (state, allocate_image) } fn view(&self) -> Element<'_, Message, Theme, Renderer> { - if let Some(allocation) = self.image_display.as_ref() { - widget::image(allocation.handle()).expand(true).into() - } else { - widget::space().into() - } + widget::column([ + if let Some(allocation) = self.image_display.as_ref() { + widget::image::viewer(allocation.handle().clone()) + .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() + }, + if let Some(error) = self.error.as_ref() { + widget::text(error).style(widget::text::danger).into() + } else { + widget::space().into() + }, + ]) + .into() } fn update(&mut self, message: Message) -> Task { match message { @@ -74,17 +85,34 @@ impl State { } } - Key::Character("q") => return window::latest().and_then(window::close), + Key::Character("o") => { + self.error = self.open_image(); + return self.allocate_image(); + } Key::Character("r") => { - self.image = imageops::rotate90(&self.image); + let Some(image) = self.image.as_ref() else { + eprintln!("no image to rotate"); + return Task::none(); + }; + self.image = Some(imageops::rotate90(image)); return self.allocate_image(); } Key::Character("i") => { - imageops::invert(&mut self.image); + let Some(image) = self.image.as_mut() else { + eprintln!("no image to invert"); + return Task::none(); + }; + imageops::invert(image); return self.allocate_image(); } + Key::Character("s") => { + self.error = self.save_image(); + } + + Key::Character("q") => return window::latest().and_then(window::close), + // ignore unused keys _ => {} }, @@ -96,11 +124,78 @@ impl State { Task::none() } + fn open_image(&mut self) -> Option { + let Some(path) = FileDialog::new() + .add_filter( + "image", + &[ + "png", "PNG", "jpg", "JPG", "jpeg", "JPEG", "avif", "bmp", "exr", "ff", "gif", + "hdr", "ico", "pnm", "qoi", "tga", "tiff", "webp", + ], + ) + .pick_file() + else { + return Some("no path to open provided".into()); + }; + + let reader = match ImageReader::open(path) { + Ok(r) => r, + Err(e) => return Some(format!("failed to create reader: {e}")), + }; + + let decoded_image = match reader.decode() { + Ok(i) => i, + Err(e) => return Some(format!("failed to decode image: {e}")), + }; + + self.image = Some(decoded_image.into_rgba8()); + + None + } + + fn save_image(&self) -> Option { + let Some(image) = self.image.as_ref() else { + return Some("no image to save".into()); + }; + + let Some(path) = FileDialog::new().save_file() else { + return Some("no path to save provided".into()); + }; + + match fs::File::create(&path) { + Err(e) => return Some(format!("failed to create file: {e}")), + Ok(file) => { + let maybe_encoder = match path.extension().map(OsStr::to_string_lossy).as_deref() { + Some("webp") => Some(WebPEncoder::new_lossless(file)), + + None | Some(_) => None, + }; + + let result = if let Some(encoder) = maybe_encoder { + image.write_with_encoder(encoder) + } else { + image.save(path) + }; + + if let Err(e) = result { + return Some(format!("failed to save image: {e}")); + } + } + }; + + None + } + fn allocate_image(&self) -> Task { + let Some(image) = self.image.as_ref() else { + eprintln!("no image to allocate"); + return Task::none(); + }; + widget::image::allocate(widget::image::Handle::from_rgba( - self.image.width(), - self.image.height(), - unsafe { std::mem::transmute::<_, &'static [u8]>(self.image.as_bytes()) }, + image.width(), + image.height(), + unsafe { std::mem::transmute::<_, &'static [u8]>(image.as_bytes()) }, )) .map(Message::ImageDisplayReady) } diff --git a/src/usage.txt b/src/usage.txt new file mode 100644 index 0000000..94cf3cf --- /dev/null +++ b/src/usage.txt @@ -0,0 +1,5 @@ +"o" to open a new image +"r" to rotate +"i" to invert colors +"s" to save the image +"q" to quit