Compare commits

...
Author SHA1 Message Date
549c478a8c
feat: capture errors into the field 2026-07-01 09:52:20 -07:00
ad0b37d0c3
refactor: add error field and display 2026-07-01 09:37:25 -07:00
c8810f3e86
fix: icon missing from source
cranelib minimizes the dependancy-only build itself anyway
2026-07-01 09:15:32 -07:00
ab8dc257e9
feat: usage info on start 2026-07-01 08:56:03 -07:00
f8ab334cd7
fix: executable path to wrap 2026-07-01 07:24:04 -07:00
0523c8e926
feat: add most appropriate MIME types
https://www.iana.org/assignments/media-types/media-types.xhtml#image
2026-07-01 07:22:44 -07:00
e703be030e
feat: use widget::image::Viewer to allow zooming and panning 2026-06-30 23:00:08 -07:00
e9fb349352
feat: "o" to open image 2026-06-30 22:53:14 -07:00
a786580da2
fix: add zenity
file save dialogs are only provided by zenity on linux it seems
2026-06-30 21:18:09 -07:00
68964f24fd
feat: saving image
dialog is missing!!
2026-06-29 08:46:45 -07:00
5 changed files with 193 additions and 38 deletions

36
Cargo.lock generated
View file

@ -812,6 +812,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.13.0",
"block2 0.6.2",
"libc",
"objc2 0.6.4", "objc2 0.6.4",
] ]
@ -1647,6 +1649,7 @@ dependencies = [
"dirs", "dirs",
"iced", "iced",
"image", "image",
"rfd",
] ]
[[package]] [[package]]
@ -2719,6 +2722,12 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "portable-atomic" name = "portable-atomic"
version = "1.13.1" version = "1.13.1"
@ -3019,6 +3028,33 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" 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]] [[package]]
name = "rgb" name = "rgb"
version = "0.8.53" version = "0.8.53"

View file

@ -7,3 +7,4 @@ edition = "2024"
iced = { version = "0.14.0", features = [ "image" ] } iced = { version = "0.14.0", features = [ "image" ] }
dirs = "6.0.0" dirs = "6.0.0"
image = "0.25.10" image = "0.25.10"
rfd = "0.17.2"

View file

@ -40,7 +40,7 @@
buildInputs = with pkgs; [ buildInputs = with pkgs; [
]; ];
src = craneLib.cleanCargoSource ./.; src = ./.;
}; };
LD_LIBRARY_PATH = lib.makeLibraryPath dlDeps; LD_LIBRARY_PATH = lib.makeLibraryPath dlDeps;
@ -58,6 +58,16 @@
desktopName = name; desktopName = name;
icon = name; icon = name;
exec = name; exec = name;
mimeTypes = [
"image/png"
"image/jpeg"
"image/gif"
"image/webp"
"image/avif"
"image/tiff"
"image/bmp"
"image/vnd.microsoft.icon" # .ico
];
}; };
in in
craneLib.buildPackage ( craneLib.buildPackage (
@ -65,12 +75,16 @@
// { // {
inherit cargoArtifacts; inherit cargoArtifacts;
nativeBuildInputs = commonArgs.nativeBuildInputs ++ [ nativeBuildInputs =
commonArgs.nativeBuildInputs
++ [
pkgs.autoPatchelfHook pkgs.autoPatchelfHook
]; ]
++ lib.optional pkgs.stdenv.hostPlatform.isLinux pkgs.makeBinaryWrapper;
buildInputs = commonArgs.buildInputs ++ [ buildInputs = commonArgs.buildInputs ++ [
pkgs.libgcc pkgs.libgcc
pkgs.zenity
]; ];
runtimeDependencies = dlDeps; runtimeDependencies = dlDeps;
@ -80,6 +94,10 @@
postFixup = '' postFixup = ''
mkdir -p "$out/share/applications" mkdir -p "$out/share/applications"
ln -s "${desktopItem}"/share/applications/* "$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 ]}
''; '';
} }
); );

View file

@ -1,11 +1,12 @@
use std::env; use std::{env, ffi::OsStr, fs};
use iced::{ use iced::{
Color, Element, Renderer, Subscription, Task, Theme, color, event, Alignment, Color, Element, Length, Renderer, Subscription, Task, Theme, color, event,
keyboard::{self, Key, key}, keyboard::{self, Key, key},
theme, widget, window, 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)] #[derive(Clone, Debug)]
enum Message { enum Message {
@ -16,43 +17,53 @@ enum Message {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct State { struct State {
image: RgbaImage, image: Option<RgbaImage>,
image_display: Option<widget::image::Allocation>, image_display: Option<widget::image::Allocation>,
error: Option<String>,
} }
impl State { impl State {
fn new() -> (Self, Task<Message>) { fn new() -> (Self, Task<Message>) {
let image = if let Some(path) = env::args().nth(1) { let state = State {
image_display: None,
image: env::args().nth(1).and_then(|path| {
Some(
ImageReader::open(path) ImageReader::open(path)
.unwrap() .unwrap()
.decode() .decode()
.unwrap() .unwrap()
.into_rgba8() .into_rgba8(),
} else { )
let mut image = RgbaImage::new(100, 100); }),
imageops::horizontal_gradient( error: None,
&mut image,
Rgba::from_slice(&[0, 0, 0, 0]),
Rgba::from_slice(&[255, 255, 255, 255]),
);
image
};
let state = State {
image_display: None,
image,
}; };
let allocate_image = state.allocate_image(); let allocate_image = state.allocate_image();
(state, allocate_image) (state, allocate_image)
} }
fn view(&self) -> Element<'_, Message, Theme, Renderer> { fn view(&self) -> Element<'_, Message, Theme, Renderer> {
widget::column([
if let Some(allocation) = self.image_display.as_ref() { 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::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 { } else {
widget::space().into() widget::space().into()
} },
])
.into()
} }
fn update(&mut self, message: Message) -> Task<Message> { fn update(&mut self, message: Message) -> Task<Message> {
match message { 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") => { 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(); return self.allocate_image();
} }
Key::Character("i") => { 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(); 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 // ignore unused keys
_ => {} _ => {}
}, },
@ -96,11 +124,78 @@ impl State {
Task::none() Task::none()
} }
fn open_image(&mut self) -> Option<String> {
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<String> {
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<Message> { fn allocate_image(&self) -> Task<Message> {
let Some(image) = self.image.as_ref() else {
eprintln!("no image to allocate");
return Task::none();
};
widget::image::allocate(widget::image::Handle::from_rgba( widget::image::allocate(widget::image::Handle::from_rgba(
self.image.width(), image.width(),
self.image.height(), image.height(),
unsafe { std::mem::transmute::<_, &'static [u8]>(self.image.as_bytes()) }, unsafe { std::mem::transmute::<_, &'static [u8]>(image.as_bytes()) },
)) ))
.map(Message::ImageDisplayReady) .map(Message::ImageDisplayReady)
} }

5
src/usage.txt Normal file
View file

@ -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