use std::{ borrow::Cow, env, ffi::OsStr, os::unix::fs::MetadataExt, path::{Path, PathBuf}, }; use iced::{ Alignment, Color, Length, Renderer, Subscription, Task, Theme, Widget, clipboard, event, keyboard::{self, Key, key}, theme, widget, window, }; use image::{EncodableLayout, RgbaImage, imageops}; use size::Size; mod utils; #[derive(Clone, Debug)] enum Message { Open, Rotate, Invert, Filter, Bar, Save, Quit, Yank, Put, ImageYanked(Result<(), clipboard::Error>), ImagePut(Result), FocusNext, FocusPrevious, ImagePicked(Result>), ImageDisplayReady(Result), SavePathPicked(Result>), FileDropped(PathBuf), } #[derive(Default)] struct State { image: Option, image_display: Option, image_filter: widget::image::FilterMethod, error: Option>, info: Option, infobar_shown: bool, } impl State { fn new() -> (Self, Task) { let mut state = Self::default(); if let Some(path) = env::args().nth(1) { match state.load_image(path) { Err(e) => state.error = Some(e.into()), Ok(task) => return (state, task), } } (state, Task::none()) } fn view(&self) -> impl Widget { 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) .boxed() }, |allocation| { widget::image::viewer(allocation.handle().clone()) .filter_method(self.image_filter) .max_scale(50.) .min_scale(1.) .width(Length::Fill) .height(Length::Fill) .boxed() }, ); 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) .boxed(), ); } if self.infobar_shown && self.info.is_some() { bar.push( widget::container(widget::text(self.title())) .align_x(Alignment::End) .width(Length::Fill) .boxed(), ); } widget::column([main, widget::row(bar).boxed()]) } fn update(&mut self, message: Message) -> Task { match message { Message::ImagePicked(result) => { match result.and_then(|path| self.load_image(path).map_err(Cow::from)) { Err(e) => self.error = Some(e.into()), Ok(task) => { self.error = None; return task; } } } Message::ImageDisplayReady(result) => { self.image_display = Some(result.unwrap()); } Message::SavePathPicked(result) => { self.error = result .map_err(Cow::from) .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().into()), ) }) .err(); } Message::FocusNext => return widget::operation::focus_next(), Message::FocusPrevious => return widget::operation::focus_previous(), Message::Open => { return Task::perform(utils::pick_image(), Message::ImagePicked); } Message::Rotate => 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(); } }, Message::Invert => 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(); } }, Message::Filter => { self.image_filter = match self.image_filter { widget::image::FilterMethod::Linear => widget::image::FilterMethod::Nearest, widget::image::FilterMethod::Nearest => widget::image::FilterMethod::Linear, } } Message::Bar => { self.infobar_shown = !self.infobar_shown; } Message::Save => { 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()); } Message::Quit => return window::latest().and_then(window::close), Message::Yank => { if let Some(image) = self.image.as_ref() { return clipboard::write(utils::clipboard_image_from_rgbaimage(&image)) .map(Message::ImageYanked); } else { self.error = Some("no image to yank".into()); } } Message::Put => return clipboard::read_image().map(Message::ImagePut), Message::ImageYanked(result) => { self.error = result.err().map(|e| format!("{e:?}").into()); } Message::ImagePut(result) => { self.error = result.as_ref().err().map(|e| format!("{e:?}").into()); if let Ok(image) = result { self.info = Some(format!("{}x{}", image.size.width, image.size.height,)); if (image.size.width, image.size.height) < (100, 100) { self.image_filter = widget::image::FilterMethod::Nearest; } self.image = Some(utils::rgbaimage_from_clipboard_image(image)); return self.allocate_image(); } } Message::FileDropped(path) => match self.load_image(path) { Ok(task) => { self.error = None; return task; } Err(e) => self.error = Some(e.into()), }, } Task::none() } fn load_image(&mut self, path: impl AsRef) -> Result, String> { utils::load_image(&path).map(|image| { self.info = Some(format!( "{} {} {}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()) ), image.width(), image.height(), )); if image.dimensions() < (100, 100) { self.image_filter = widget::image::FilterMethod::Nearest; } self.image = Some(image); self.allocate_image() }) } 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( 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()) }, )) .map(Message::ImageDisplayReady) } fn subscription(&self) -> Subscription { event::listen().filter_map(|event| match event { iced::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => { match key.as_ref() { Key::Named(key::Named::Tab) => Some(if modifiers.shift() { Message::FocusPrevious } else { Message::FocusNext }), Key::Character("o") => Some(Message::Open), Key::Character("r") => Some(Message::Rotate), Key::Character("i") => Some(Message::Invert), Key::Character("f") => Some(Message::Filter), Key::Character("b") => Some(Message::Bar), Key::Character("s") => Some(Message::Save), Key::Character("q") => Some(Message::Quit), Key::Character("y") | Key::Character("c") => Some(Message::Yank), Key::Character("p") | Key::Character("v") => Some(Message::Put), _ => None, } } iced::Event::Window(window::Event::FileDropped(path)) => { Some(Message::FileDropped(path)) } _ => None, }) } 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), ) }, ) } } fn main() -> Result<(), iced::Error> { jxl_oxide::integration::register_image_decoding_hook(); misc_image_integration::register(); iced::application(State::new, State::update, State::view) .subscription(State::subscription) .title(State::title) .theme(Theme::custom( "custom", theme::palette::Seed { background: Color::BLACK, text: Color::WHITE, ..theme::palette::Seed::DARK }, )) .run() }