without updating, I had an issue with hardware rendering
that made it fall back to slightly stuttery software rendering each time.
no error messages from iced or wgpu,
but I did get the following when running it with mangohud:
shim: Failed to load from "/nix/store/69q712zji0lqph80g6k75z6d3jhbbmb7-mangohud-0.8.4/lib/mangohud/libMangoHud_opengl.so": /nix/store/57iz36553175g3178pvxjij8z5rcsd4n-glibc-2.42-61/lib/libm.so.6: version `GLIBC_2.44' not found (required by /nix/store/69q712zji0lqph80g6k75z6d3jhbbmb7-mangohud-0.8.4/lib/mangohud/libMangoHud_opengl.so)
shim: Failed to load from ${ORIGIN}/libMangoHud_opengl.so: /nix/store/57iz36553175g3178pvxjij8z5rcsd4n-glibc-2.42-61/lib/libm.so.6: version `GLIBC_2.44' not found (required by /nix/store/69q712zji0lqph80g6k75z6d3jhbbmb7-mangohud-0.8.4/lib/mangohud/libMangoHud_opengl.so)
325 lines
11 KiB
Rust
325 lines
11 KiB
Rust
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<clipboard::Image, clipboard::Error>),
|
|
|
|
FocusNext,
|
|
FocusPrevious,
|
|
|
|
ImagePicked(Result<PathBuf, Cow<'static, str>>),
|
|
ImageDisplayReady(Result<widget::image::Allocation, widget::image::Error>),
|
|
SavePathPicked(Result<PathBuf, Cow<'static, str>>),
|
|
|
|
FileDropped(PathBuf),
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct State {
|
|
image: Option<RgbaImage>,
|
|
image_display: Option<widget::image::Allocation>,
|
|
image_filter: widget::image::FilterMethod,
|
|
|
|
error: Option<Cow<'static, str>>,
|
|
info: Option<String>,
|
|
infobar_shown: bool,
|
|
}
|
|
impl State {
|
|
fn new() -> (Self, Task<Message>) {
|
|
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<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)
|
|
.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<Message> {
|
|
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<Path>) -> Result<Task<Message>, 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<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(
|
|
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<Message> {
|
|
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()
|
|
}
|