From 68964f24fd084b6257d979336c3ecf6508f7681e Mon Sep 17 00:00:00 2001 From: electria Date: Mon, 29 Jun 2026 08:46:45 -0700 Subject: [PATCH 01/10] feat: saving image dialog is missing!! --- Cargo.lock | 36 ++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + src/main.rs | 36 ++++++++++++++++++++++++++++++++++-- 3 files changed, 71 insertions(+), 2 deletions(-) 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/src/main.rs b/src/main.rs index 2e6973d..604f98b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,14 @@ -use std::env; +use std::{env, ffi::OsStr, fs}; use iced::{ Color, Element, 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, Pixel, Rgba, RgbaImage, codecs::webp::WebPEncoder, imageops, +}; +use rfd::FileDialog; #[derive(Clone, Debug)] enum Message { @@ -85,6 +88,35 @@ impl State { return self.allocate_image(); } + Key::Character("s") => { + let Some(path) = FileDialog::new().save_file() else { + eprintln!("no path to save provided"); + return Task::none(); + }; + + let Ok(file) = fs::File::create(&path) + .inspect_err(|e| eprintln!("failed to create file: {e}")) + else { + return Task::none(); + }; + + 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 { + self.image.write_with_encoder(encoder) + } else { + self.image.save(path) + }; + + #[allow(unused_must_use)] + result.inspect_err(|e| eprintln!("failed to save image: {e}")); + } + // ignore unused keys _ => {} }, From a786580da2a9fc438c8828dcb376deb20de830e8 Mon Sep 17 00:00:00 2001 From: electria Date: Tue, 30 Jun 2026 21:18:09 -0700 Subject: [PATCH 02/10] fix: add zenity file save dialogs are only provided by zenity on linux it seems --- flake.nix | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/flake.nix b/flake.nix index 35174c5..4916461 100644 --- a/flake.nix +++ b/flake.nix @@ -65,12 +65,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 +84,10 @@ postFixup = '' mkdir -p "$out/share/applications" ln -s "${desktopItem}"/share/applications/* "$out/share/applications/" + '' + + lib.optionalString pkgs.stdenv.hostPlatform.isLinux /* sh */ '' + wrapProgram $out/${name} --inherit-argv0 \ + --prefix PATH : ${lib.makeBinPath [ pkgs.zenity ]} ''; } ); From e9fb349352eb13cdd7f7edeb1b4363e3956337c0 Mon Sep 17 00:00:00 2001 From: electria Date: Tue, 30 Jun 2026 22:53:14 -0700 Subject: [PATCH 03/10] feat: "o" to open image --- src/main.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/main.rs b/src/main.rs index 604f98b..2866ca7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -88,6 +88,29 @@ impl State { return self.allocate_image(); } + Key::Character("o") => { + 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 { + eprintln!("no path to open provided"); + return Task::none(); + }; + + self.image = ImageReader::open(path) + .unwrap() + .decode() + .unwrap() + .into_rgba8(); + + return self.allocate_image(); + } Key::Character("s") => { let Some(path) = FileDialog::new().save_file() else { eprintln!("no path to save provided"); From e703be030e7fc7dcc2327e446ed9688a9455e969 Mon Sep 17 00:00:00 2001 From: electria Date: Tue, 30 Jun 2026 23:00:08 -0700 Subject: [PATCH 04/10] feat: use widget::image::Viewer to allow zooming and panning --- src/main.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 2866ca7..8ac0505 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use std::{env, ffi::OsStr, fs}; use iced::{ - Color, Element, Renderer, Subscription, Task, Theme, color, event, + Color, Element, Length, Renderer, Subscription, Task, Theme, color, event, keyboard::{self, Key, key}, theme, widget, window, }; @@ -52,7 +52,10 @@ impl State { } fn view(&self) -> Element<'_, Message, Theme, Renderer> { if let Some(allocation) = self.image_display.as_ref() { - widget::image(allocation.handle()).expand(true).into() + widget::image::viewer(allocation.handle().clone()) + .width(Length::Fill) + .height(Length::Fill) + .into() } else { widget::space().into() } From 0523c8e926ae6993b937661295a33af37394efb5 Mon Sep 17 00:00:00 2001 From: electria Date: Wed, 1 Jul 2026 07:22:44 -0700 Subject: [PATCH 05/10] feat: add most appropriate MIME types https://www.iana.org/assignments/media-types/media-types.xhtml#image --- flake.nix | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/flake.nix b/flake.nix index 4916461..cfd87ba 100644 --- a/flake.nix +++ b/flake.nix @@ -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 ( From f8ab334cd732ee8b8f0448875b9df28f071200a2 Mon Sep 17 00:00:00 2001 From: electria Date: Wed, 1 Jul 2026 07:24:04 -0700 Subject: [PATCH 06/10] fix: executable path to wrap --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index cfd87ba..66737b5 100644 --- a/flake.nix +++ b/flake.nix @@ -96,7 +96,7 @@ ln -s "${desktopItem}"/share/applications/* "$out/share/applications/" '' + lib.optionalString pkgs.stdenv.hostPlatform.isLinux /* sh */ '' - wrapProgram $out/${name} --inherit-argv0 \ + wrapProgram $out/bin/${name} --inherit-argv0 \ --prefix PATH : ${lib.makeBinPath [ pkgs.zenity ]} ''; } From ab8dc257e987898fcaf67354b3db659c652e5f28 Mon Sep 17 00:00:00 2001 From: electria Date: Wed, 1 Jul 2026 08:56:03 -0700 Subject: [PATCH 07/10] feat: usage info on start --- src/main.rs | 106 ++++++++++++++++++++++++++++---------------------- src/usage.txt | 5 +++ 2 files changed, 65 insertions(+), 46 deletions(-) create mode 100644 src/usage.txt diff --git a/src/main.rs b/src/main.rs index 8ac0505..14febde 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,11 @@ use std::{env, ffi::OsStr, fs}; use iced::{ - Color, Element, Length, 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, codecs::webp::WebPEncoder, imageops, -}; +use image::{EncodableLayout, ImageReader, RgbaImage, codecs::webp::WebPEncoder, imageops}; use rfd::FileDialog; #[derive(Clone, Debug)] @@ -19,32 +17,22 @@ enum Message { #[derive(Clone, Debug)] struct State { - image: RgbaImage, + image: Option, image_display: 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(), + ) + }), }; let allocate_image = state.allocate_image(); @@ -57,7 +45,12 @@ impl State { .height(Length::Fill) .into() } else { - widget::space().into() + widget::container(widget::text(include_str!("usage.txt"))) + .height(Length::Fill) + .width(Length::Fill) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .into() } } fn update(&mut self, message: Message) -> Task { @@ -80,17 +73,6 @@ impl State { } } - Key::Character("q") => return window::latest().and_then(window::close), - - Key::Character("r") => { - self.image = imageops::rotate90(&self.image); - return self.allocate_image(); - } - Key::Character("i") => { - imageops::invert(&mut self.image); - return self.allocate_image(); - } - Key::Character("o") => { let Some(path) = FileDialog::new() .add_filter( @@ -106,15 +88,40 @@ impl State { return Task::none(); }; - self.image = ImageReader::open(path) - .unwrap() - .decode() - .unwrap() - .into_rgba8(); + self.image = Some( + ImageReader::open(path) + .unwrap() + .decode() + .unwrap() + .into_rgba8(), + ); return self.allocate_image(); } + + Key::Character("r") => { + 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") => { + 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") => { + let Some(image) = self.image.as_ref() else { + eprintln!("no image to save"); + return Task::none(); + }; + let Some(path) = FileDialog::new().save_file() else { eprintln!("no path to save provided"); return Task::none(); @@ -134,15 +141,17 @@ impl State { }; let result = if let Some(encoder) = maybe_encoder { - self.image.write_with_encoder(encoder) + image.write_with_encoder(encoder) } else { - self.image.save(path) + image.save(path) }; #[allow(unused_must_use)] result.inspect_err(|e| eprintln!("failed to save image: {e}")); } + Key::Character("q") => return window::latest().and_then(window::close), + // ignore unused keys _ => {} }, @@ -155,10 +164,15 @@ impl State { } 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 From c8810f3e86963496e1117a005ddd74da5a08da9e Mon Sep 17 00:00:00 2001 From: electria Date: Wed, 1 Jul 2026 09:15:32 -0700 Subject: [PATCH 08/10] fix: icon missing from source cranelib minimizes the dependancy-only build itself anyway --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 66737b5..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; From ad0b37d0c33a6e6f80de526ead8dbf3d26089efe Mon Sep 17 00:00:00 2001 From: electria Date: Wed, 1 Jul 2026 09:36:46 -0700 Subject: [PATCH 09/10] refactor: add error field and display --- src/main.rs | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/main.rs b/src/main.rs index 14febde..dfc0ef0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,8 @@ enum Message { struct State { image: Option, image_display: Option, + + error: Option, } impl State { fn new() -> (Self, Task) { @@ -33,25 +35,35 @@ impl State { .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::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() - } + 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 { From 549c478a8c74b2c9709441f5625b4ed858eb337c Mon Sep 17 00:00:00 2001 From: electria Date: Wed, 1 Jul 2026 09:52:20 -0700 Subject: [PATCH 10/10] feat: capture errors into the field --- src/main.rs | 117 ++++++++++++++++++++++++++++------------------------ 1 file changed, 64 insertions(+), 53 deletions(-) diff --git a/src/main.rs b/src/main.rs index dfc0ef0..663d849 100644 --- a/src/main.rs +++ b/src/main.rs @@ -86,28 +86,7 @@ impl State { } Key::Character("o") => { - 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 { - eprintln!("no path to open provided"); - return Task::none(); - }; - - self.image = Some( - ImageReader::open(path) - .unwrap() - .decode() - .unwrap() - .into_rgba8(), - ); - + self.error = self.open_image(); return self.allocate_image(); } @@ -129,37 +108,7 @@ impl State { } Key::Character("s") => { - let Some(image) = self.image.as_ref() else { - eprintln!("no image to save"); - return Task::none(); - }; - - let Some(path) = FileDialog::new().save_file() else { - eprintln!("no path to save provided"); - return Task::none(); - }; - - let Ok(file) = fs::File::create(&path) - .inspect_err(|e| eprintln!("failed to create file: {e}")) - else { - return Task::none(); - }; - - 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) - }; - - #[allow(unused_must_use)] - result.inspect_err(|e| eprintln!("failed to save image: {e}")); + self.error = self.save_image(); } Key::Character("q") => return window::latest().and_then(window::close), @@ -175,6 +124,68 @@ 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");