Merge pull request #1 from NotAShelf/notashelf/push-kxnwssmwortt
treewide: rewrite with Iced; migrate to new repository format
This commit is contained in:
commit
93440ada8a
20 changed files with 5088 additions and 979 deletions
2
.envrc
Normal file
2
.envrc
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
use flake
|
||||||
|
|
||||||
4461
Cargo.lock
generated
4461
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
25
Cargo.toml
25
Cargo.toml
|
|
@ -1,12 +1,21 @@
|
||||||
[package]
|
[workspace]
|
||||||
name = "lychee"
|
resolver = "3"
|
||||||
version = "0.1.0"
|
members = ["lychee", "crates/*"]
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "1.0.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[workspace.dependencies]
|
||||||
|
clap = { version = "4.6.0", features = ["derive"] }
|
||||||
|
iced = { version = "0.14.0", features = ["image", "wayland", "smol"] }
|
||||||
|
image = "0.25.10"
|
||||||
|
|
||||||
image = "0.25.5"
|
lychee-cli = { path = "./crates/lychee-cli" }
|
||||||
wayland-client = "0.31"
|
lychee-img = { path = "./crates/lychee-img" }
|
||||||
minifb = "0.28.0"
|
lychee-core = { path = "./crates/lychee-core" }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
|
|
|
||||||
84
README.md
84
README.md
|
|
@ -1,32 +1,86 @@
|
||||||
# Lychee
|
# Lychee
|
||||||
|
|
||||||
[Image crate]: https://crates.io/crates/image
|
[Image crate]: https://crates.io/crates/image
|
||||||
|
[Iced]: https://iced.rs
|
||||||
|
|
||||||
Simple, opinionated and Wayland-native image viewer using the [Image crate] .
|
Simple, opinionated image viewer using [Iced] and the [Image crate]. Built to
|
||||||
Built in response to `imv`'s high memory usage on my system, and does not
|
replace `imv` on my system, particularly in response to its high memory usage
|
||||||
implement an IPC as I consider it _YAGNI_[^1]
|
and lack of UI polish. Unlike imv, Lychee does not implement an IPC as I consider
|
||||||
|
it _YAGNI_[^1] but it improves a more powerful UI and a stronger focus on user
|
||||||
Still a work in progress, I would like to reach partial feature parity with
|
experience.
|
||||||
`imv`.
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
Using lychee is easy. Give it something to display. That's it.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
lychee path/to/image.png # or another extension.
|
# View a single image. Wow, much png.
|
||||||
|
$ lychee path/to/image.png
|
||||||
|
|
||||||
|
# View all images in directory
|
||||||
|
$ lychee path/to/directory/
|
||||||
|
|
||||||
|
# View specific images
|
||||||
|
$ lychee image1.png image2.png
|
||||||
```
|
```
|
||||||
|
|
||||||
You may use the `--same-size` flag to create a window that is the _same size as
|
This can be an image, multiple images, or a path containing images.
|
||||||
the image that is opening_. Success of this flag depends on your desktop
|
|
||||||
environment.
|
## Keybindings
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| ----------------- | ----------------- |
|
||||||
|
| `→` or `l` or `n` | Next image |
|
||||||
|
| `←` or `h` or `p` | Previous image |
|
||||||
|
| `g` | Go to first image |
|
||||||
|
| `G` | Go to last image |
|
||||||
|
|
||||||
|
### Zoom
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| ----------------- | ----------------- |
|
||||||
|
| `↑` or `i` or `+` | Zoom in |
|
||||||
|
| `↓` or `o` or `-` | Zoom out |
|
||||||
|
| `0` | Fit to window |
|
||||||
|
| `1` | Actual size (1:1) |
|
||||||
|
| `r` | Reset zoom |
|
||||||
|
|
||||||
|
### Slideshow
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| ------- | ------------------------------ |
|
||||||
|
| `Space` | Toggle slideshow |
|
||||||
|
| `t` | Increase slideshow delay by 1s |
|
||||||
|
| `T` | Decrease slideshow delay by 1s |
|
||||||
|
|
||||||
|
### Window
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| ------------ | ----------------- |
|
||||||
|
| `f` | Toggle fullscreen |
|
||||||
|
| `q` or `Esc` | Quit |
|
||||||
|
|
||||||
|
### Mouse
|
||||||
|
|
||||||
|
- **Scroll**: Zoom in/out
|
||||||
|
- **Click + Drag**: Pan image
|
||||||
|
- **Window close button**: Quit
|
||||||
|
|
||||||
## Supported Image Formats
|
## Supported Image Formats
|
||||||
|
|
||||||
All formats supported by the Image crate will be supported by Lychee. For edge
|
Lychee supports a wide array of formats. Primarily, those supported by the
|
||||||
cases, please open an issue. For the time being, this should include: AVIF, BMP,
|
[Image crate] will be supported verbatim in Lychee. At the time of writing,
|
||||||
DDS, Farbfeld, GIF, HDR, ICO, JPEG, EXR, PNG, PNM, QOI, TGA, TIFF and WebP.
|
includes: AVIF, BMP, DDS, Farbfeld, GIF, HDR, ICO, JPEG, EXR, PNG, PNM, QOI,
|
||||||
|
TGA, TIFF and WebP.
|
||||||
|
|
||||||
|
More formats may be supported in the future on demand.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Lychee is available under the [Mozilla Public License Version 2.0](./LICENSE)
|
This project is made available under Mozilla Public License (MPL) version 2.0.
|
||||||
|
See [LICENSE](LICENSE) for more details on the exact conditions. An online copy
|
||||||
|
is provided [here](https://www.mozilla.org/en-US/MPL/2.0/).
|
||||||
|
|
||||||
[^1]. ["You Aren't Gonna Need It"](https://martinfowler.com/bliki/Yagni.html)
|
[^1]: ["You Aren't Gonna Need It"](https://martinfowler.com/bliki/Yagni.html)
|
||||||
|
|
|
||||||
7
crates/lychee-cli/Cargo.toml
Normal file
7
crates/lychee-cli/Cargo.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
[package]
|
||||||
|
name = "lychee-cli"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap.workspace = true
|
||||||
16
crates/lychee-cli/src/lib.rs
Normal file
16
crates/lychee-cli/src/lib.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
pub use clap::Parser as Clap;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "lychee")]
|
||||||
|
pub struct Args {
|
||||||
|
#[arg(help = "Paths to images or directories")]
|
||||||
|
pub paths: Vec<String>,
|
||||||
|
|
||||||
|
#[arg(short, long, help = "Open in fullscreen mode")]
|
||||||
|
pub fullscreen: bool,
|
||||||
|
|
||||||
|
#[arg(long, default_value_t = 5, help = "Slideshow interval in seconds")]
|
||||||
|
pub slideshow: u64,
|
||||||
|
}
|
||||||
11
crates/lychee-core/Cargo.toml
Normal file
11
crates/lychee-core/Cargo.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[package]
|
||||||
|
name = "lychee-core"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap.workspace = true
|
||||||
|
iced.workspace = true
|
||||||
|
image.workspace = true
|
||||||
|
lychee-cli.workspace = true
|
||||||
|
lychee-img.workspace = true
|
||||||
421
crates/lychee-core/src/app.rs
Normal file
421
crates/lychee-core/src/app.rs
Normal file
|
|
@ -0,0 +1,421 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use iced::event;
|
||||||
|
use iced::{
|
||||||
|
Alignment, Element, Length, Subscription, Task, keyboard,
|
||||||
|
time::{self, milliseconds},
|
||||||
|
widget::{column, container, row},
|
||||||
|
window,
|
||||||
|
};
|
||||||
|
use image::{GenericImageView, imageops::FilterType};
|
||||||
|
use lychee_cli::{Args, Clap};
|
||||||
|
use lychee_img::collect_paths;
|
||||||
|
|
||||||
|
use crate::keybindings::handle_key_event;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Message {
|
||||||
|
Next,
|
||||||
|
Prev,
|
||||||
|
GoToFirst,
|
||||||
|
GoToLast,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
|
ActualSize,
|
||||||
|
FitWindow,
|
||||||
|
ResetView,
|
||||||
|
Pan(i32, i32),
|
||||||
|
RotateCW,
|
||||||
|
RotateCCW,
|
||||||
|
FlipHorizontal,
|
||||||
|
FlipVertical,
|
||||||
|
ResetTransform,
|
||||||
|
ToggleFullscreen,
|
||||||
|
ToggleSlideshow,
|
||||||
|
SlideshowTick,
|
||||||
|
AdjustSlideshowDelay(i64),
|
||||||
|
WindowResized(u32, u32),
|
||||||
|
Close,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
window_id: window::Id,
|
||||||
|
paths: Vec<String>,
|
||||||
|
current: usize,
|
||||||
|
// Cache key: (index, rotation_degrees, flip_h, flip_v, zoom_percent)
|
||||||
|
image_cache: HashMap<(usize, u16, bool, bool, u32), iced::widget::image::Handle>,
|
||||||
|
fullscreen: bool,
|
||||||
|
slideshow_active: bool,
|
||||||
|
slideshow_interval: u64,
|
||||||
|
// Zoom state (scale is display value, zoom_percent is cache key)
|
||||||
|
scale: f32,
|
||||||
|
min_scale: f32,
|
||||||
|
max_scale: f32,
|
||||||
|
scale_step: f32,
|
||||||
|
zoom_percent: u32,
|
||||||
|
pan_offset: (f32, f32),
|
||||||
|
// Transform state
|
||||||
|
rotation: u16,
|
||||||
|
flip_h: bool,
|
||||||
|
flip_v: bool,
|
||||||
|
// Window dimensions for fit-to-window calculation
|
||||||
|
window_size: (u32, u32),
|
||||||
|
// Original image dimensions for fit-to-window calculation
|
||||||
|
current_image_size: (u32, u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn new() -> (Self, Task<Message>) {
|
||||||
|
let args = Args::parse();
|
||||||
|
let paths = collect_paths(args.paths);
|
||||||
|
let window_id = window::Id::unique();
|
||||||
|
|
||||||
|
let mut app = Self {
|
||||||
|
window_id,
|
||||||
|
paths,
|
||||||
|
current: 0,
|
||||||
|
image_cache: HashMap::new(),
|
||||||
|
fullscreen: args.fullscreen,
|
||||||
|
slideshow_active: false,
|
||||||
|
slideshow_interval: args.slideshow,
|
||||||
|
// Zoom state
|
||||||
|
scale: 1.0,
|
||||||
|
min_scale: 0.1,
|
||||||
|
max_scale: 10.0,
|
||||||
|
scale_step: 0.1,
|
||||||
|
zoom_percent: 100,
|
||||||
|
pan_offset: (0.0, 0.0),
|
||||||
|
// Transform state
|
||||||
|
rotation: 0,
|
||||||
|
flip_h: false,
|
||||||
|
flip_v: false,
|
||||||
|
// Window and image dimensions
|
||||||
|
window_size: (800, 600), // Default, will be updated by resize events
|
||||||
|
current_image_size: (0, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Preload initial images into cache
|
||||||
|
app.preload_adjacent();
|
||||||
|
|
||||||
|
let task = if args.fullscreen {
|
||||||
|
window::maximize(window_id, true)
|
||||||
|
} else {
|
||||||
|
Task::none()
|
||||||
|
};
|
||||||
|
|
||||||
|
(app, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn view(&self) -> Element<'_, Message> {
|
||||||
|
let image_content: Element<'_, Message> = match self.image_cache.get(&(
|
||||||
|
self.current,
|
||||||
|
self.rotation,
|
||||||
|
self.flip_h,
|
||||||
|
self.flip_v,
|
||||||
|
self.zoom_percent,
|
||||||
|
)) {
|
||||||
|
Some(handle) => {
|
||||||
|
// Image displayed at actual pixel dimensions (Shrink)
|
||||||
|
let img: iced::widget::Image<iced::widget::image::Handle> =
|
||||||
|
iced::widget::Image::new(handle.clone());
|
||||||
|
|
||||||
|
// Center the image in a Fill-sized container
|
||||||
|
container(img)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.align_x(Alignment::Center)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
None => iced::widget::text("Loading...").into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Main layout: image on top, statusbar at bottom
|
||||||
|
let statusbar: Element<'_, Message> = self.render_statusbar();
|
||||||
|
|
||||||
|
column![image_content, statusbar].into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_statusbar(&self) -> Element<'_, Message> {
|
||||||
|
let zoom_text = format!("{:.*}%", 0, self.scale * 100.0);
|
||||||
|
let index_text = format!("{}/{}", self.current + 1, self.paths.len());
|
||||||
|
|
||||||
|
row![
|
||||||
|
iced::widget::text(zoom_text).size(14),
|
||||||
|
iced::widget::text(index_text).size(14),
|
||||||
|
]
|
||||||
|
.padding(8)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subscription(&self) -> Subscription<Message> {
|
||||||
|
let hotkey = keyboard::listen().filter_map(|event| {
|
||||||
|
if let keyboard::Event::KeyPressed { key, modifiers, .. } = event {
|
||||||
|
handle_key_event(
|
||||||
|
&key,
|
||||||
|
modifiers.control(),
|
||||||
|
modifiers.shift(),
|
||||||
|
modifiers.is_empty(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let close = window::close_events().map(|_| Message::Close);
|
||||||
|
let slideshow = time::every(milliseconds(self.slideshow_interval * 1000))
|
||||||
|
.map(|_| Message::SlideshowTick);
|
||||||
|
let resize = event::listen().filter_map(|event| {
|
||||||
|
if let iced::Event::Window(window::Event::Resized(size)) = event {
|
||||||
|
Some(Message::WindowResized(
|
||||||
|
size.width as u32,
|
||||||
|
size.height as u32,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Subscription::batch(vec![hotkey, close, slideshow, resize])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preload_adjacent(&mut self) {
|
||||||
|
for offset in [0isize, -1, 1] {
|
||||||
|
let idx = (self.current as isize + offset) as usize;
|
||||||
|
let cache_key = (
|
||||||
|
idx,
|
||||||
|
self.rotation,
|
||||||
|
self.flip_h,
|
||||||
|
self.flip_v,
|
||||||
|
self.zoom_percent,
|
||||||
|
);
|
||||||
|
if idx < self.paths.len() && !self.image_cache.contains_key(&cache_key) {
|
||||||
|
// Extract path to avoid borrow conflict
|
||||||
|
let path = self.paths[idx].clone();
|
||||||
|
let zoom = self.zoom_percent;
|
||||||
|
if let Some(handle) = self.load_image(&path, zoom) {
|
||||||
|
self.image_cache.insert(cache_key, handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_image(&mut self, path: &str, zoom_percent: u32) -> Option<iced::widget::image::Handle> {
|
||||||
|
let mut img = ::image::open(path).ok()?;
|
||||||
|
|
||||||
|
// Apply rotation
|
||||||
|
match self.rotation {
|
||||||
|
90 => img = img.rotate90(),
|
||||||
|
180 => img = img.rotate180(),
|
||||||
|
270 => img = img.rotate270(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply flips
|
||||||
|
if self.flip_h {
|
||||||
|
img = img.fliph();
|
||||||
|
}
|
||||||
|
if self.flip_v {
|
||||||
|
img = img.flipv();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track original image dimensions for fit-to-window calculation
|
||||||
|
let (orig_width, orig_height) = img.dimensions();
|
||||||
|
self.current_image_size = (orig_width, orig_height);
|
||||||
|
|
||||||
|
// Apply zoom by resizing
|
||||||
|
let new_width = ((orig_width as f32) * (zoom_percent as f32) / 100.0).max(1.0) as u32;
|
||||||
|
let new_height = ((orig_height as f32) * (zoom_percent as f32) / 100.0).max(1.0) as u32;
|
||||||
|
|
||||||
|
// Only resize if not 100%
|
||||||
|
let img = if zoom_percent != 100 {
|
||||||
|
img.resize(new_width, new_height, FilterType::Lanczos3)
|
||||||
|
} else {
|
||||||
|
img
|
||||||
|
};
|
||||||
|
|
||||||
|
let (width, height) = img.dimensions();
|
||||||
|
let raw = img.to_rgba8().into_raw();
|
||||||
|
Some(iced::widget::image::Handle::from_rgba(width, height, raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalidate_cache(&mut self) {
|
||||||
|
// Invalidate current and adjacent images at current zoom level
|
||||||
|
for offset in [0isize, -1, 1] {
|
||||||
|
let idx = (self.current as isize + offset) as usize;
|
||||||
|
let key = (
|
||||||
|
idx,
|
||||||
|
self.rotation,
|
||||||
|
self.flip_h,
|
||||||
|
self.flip_v,
|
||||||
|
self.zoom_percent,
|
||||||
|
);
|
||||||
|
self.image_cache.remove(&key);
|
||||||
|
}
|
||||||
|
self.preload_adjacent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure the current image is loaded into cache We'll load it on-demand if missing.
|
||||||
|
fn ensure_current_loaded(&mut self) {
|
||||||
|
let key = (
|
||||||
|
self.current,
|
||||||
|
self.rotation,
|
||||||
|
self.flip_h,
|
||||||
|
self.flip_v,
|
||||||
|
self.zoom_percent,
|
||||||
|
);
|
||||||
|
if !self.image_cache.contains_key(&key) {
|
||||||
|
// Extract path to avoid borrow conflict
|
||||||
|
let path = self.paths[self.current].clone();
|
||||||
|
let zoom = self.zoom_percent;
|
||||||
|
if let Some(handle) = self.load_image(&path, zoom) {
|
||||||
|
self.image_cache.insert(key, handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||||
|
// Ensure current image is loaded; this handles edge cases where
|
||||||
|
// cache miss occurred and preload hasn't triggered yet
|
||||||
|
self.ensure_current_loaded();
|
||||||
|
|
||||||
|
match message {
|
||||||
|
Message::Next => {
|
||||||
|
if self.current < self.paths.len() - 1 {
|
||||||
|
self.current += 1;
|
||||||
|
self.preload_adjacent();
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::Prev => {
|
||||||
|
if self.current > 0 {
|
||||||
|
self.current -= 1;
|
||||||
|
self.preload_adjacent();
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::GoToFirst => {
|
||||||
|
if !self.paths.is_empty() {
|
||||||
|
self.current = 0;
|
||||||
|
self.preload_adjacent();
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::GoToLast => {
|
||||||
|
if !self.paths.is_empty() {
|
||||||
|
self.current = self.paths.len() - 1;
|
||||||
|
self.preload_adjacent();
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::ZoomIn => {
|
||||||
|
let new_percent =
|
||||||
|
(self.zoom_percent as f32 * (1.0 + self.scale_step)).round() as u32;
|
||||||
|
self.zoom_percent = new_percent.min((self.max_scale * 100.0) as u32);
|
||||||
|
self.scale = self.zoom_percent as f32 / 100.0;
|
||||||
|
self.invalidate_cache();
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::ZoomOut => {
|
||||||
|
let new_percent =
|
||||||
|
(self.zoom_percent as f32 * (1.0 / (1.0 + self.scale_step))).round() as u32;
|
||||||
|
self.zoom_percent = new_percent.max((self.min_scale * 100.0) as u32);
|
||||||
|
self.scale = self.zoom_percent as f32 / 100.0;
|
||||||
|
self.invalidate_cache();
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::ActualSize | Message::FitWindow | Message::ResetView => {
|
||||||
|
let fit_zoom = if self.current_image_size.0 > 0 && self.current_image_size.1 > 0 {
|
||||||
|
// Calculate zoom to fit image within window, preserving aspect ratio
|
||||||
|
let window_ratio = (self.window_size.0 as f32) / (self.window_size.1 as f32);
|
||||||
|
let image_ratio =
|
||||||
|
(self.current_image_size.0 as f32) / (self.current_image_size.1 as f32);
|
||||||
|
|
||||||
|
if window_ratio > image_ratio {
|
||||||
|
// Window is wider than image, fit by height
|
||||||
|
((self.window_size.1 as f32) / (self.current_image_size.1 as f32) * 100.0)
|
||||||
|
as u32
|
||||||
|
} else {
|
||||||
|
// Window is taller than image, fit by width
|
||||||
|
((self.window_size.0 as f32) / (self.current_image_size.0 as f32) * 100.0)
|
||||||
|
as u32
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
100 // default to 100% if no image dimensions available
|
||||||
|
};
|
||||||
|
|
||||||
|
self.zoom_percent = fit_zoom.min((self.max_scale * 100.0) as u32);
|
||||||
|
self.scale = self.zoom_percent as f32 / 100.0;
|
||||||
|
self.pan_offset = (0.0, 0.0);
|
||||||
|
self.invalidate_cache();
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::WindowResized(width, height) => {
|
||||||
|
self.window_size = (width, height);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::Pan(dx, dy) => {
|
||||||
|
// XXX: pan_offset is tracked but visual panning requires a custom
|
||||||
|
// canvas widget or Iced's scrollable with exposed State. This will
|
||||||
|
// be implemented in a future version, because I don't really have
|
||||||
|
// the energy to do so right now.
|
||||||
|
self.pan_offset.0 += dx as f32;
|
||||||
|
self.pan_offset.1 += dy as f32;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::RotateCW => {
|
||||||
|
self.rotation = (self.rotation + 90) % 360;
|
||||||
|
self.invalidate_cache();
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::RotateCCW => {
|
||||||
|
self.rotation = (self.rotation + 270) % 360;
|
||||||
|
self.invalidate_cache();
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::FlipHorizontal => {
|
||||||
|
self.flip_h = !self.flip_h;
|
||||||
|
self.invalidate_cache();
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::FlipVertical => {
|
||||||
|
self.flip_v = !self.flip_v;
|
||||||
|
self.invalidate_cache();
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::ResetTransform => {
|
||||||
|
self.scale = 1.0;
|
||||||
|
self.zoom_percent = 100;
|
||||||
|
self.pan_offset = (0.0, 0.0);
|
||||||
|
self.rotation = 0;
|
||||||
|
self.flip_h = false;
|
||||||
|
self.flip_v = false;
|
||||||
|
self.invalidate_cache();
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::ToggleFullscreen => {
|
||||||
|
self.fullscreen = !self.fullscreen;
|
||||||
|
window::maximize(self.window_id, self.fullscreen)
|
||||||
|
}
|
||||||
|
Message::ToggleSlideshow => {
|
||||||
|
self.slideshow_active = !self.slideshow_active;
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::AdjustSlideshowDelay(delta) => {
|
||||||
|
if self.slideshow_interval as i64 + delta >= 1 {
|
||||||
|
self.slideshow_interval = (self.slideshow_interval as i64 + delta) as u64;
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::SlideshowTick => {
|
||||||
|
if self.slideshow_active && !self.paths.is_empty() {
|
||||||
|
self.current = (self.current + 1) % self.paths.len();
|
||||||
|
self.preload_adjacent();
|
||||||
|
}
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
Message::Close => iced::exit(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
crates/lychee-core/src/keybindings.rs
Normal file
70
crates/lychee-core/src/keybindings.rs
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
use iced::keyboard;
|
||||||
|
|
||||||
|
use crate::Message;
|
||||||
|
|
||||||
|
pub fn handle_key_event(
|
||||||
|
key: &keyboard::Key,
|
||||||
|
_ctrl: bool,
|
||||||
|
shift: bool,
|
||||||
|
empty_mods: bool,
|
||||||
|
) -> Option<Message> {
|
||||||
|
// Handle shift-modified keys first
|
||||||
|
if shift && empty_mods {
|
||||||
|
if let keyboard::Key::Character(c) = key {
|
||||||
|
match c.as_str() {
|
||||||
|
"R" => return Some(Message::RotateCCW),
|
||||||
|
"F" => return Some(Message::FlipVertical),
|
||||||
|
"0" => return Some(Message::ResetTransform),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let keyboard::Key::Named(n) = key {
|
||||||
|
match n {
|
||||||
|
keyboard::key::Named::ArrowRight => return Some(Message::Pan(50, 0)),
|
||||||
|
keyboard::key::Named::ArrowLeft => return Some(Message::Pan(-50, 0)),
|
||||||
|
keyboard::key::Named::ArrowUp => return Some(Message::Pan(0, -50)),
|
||||||
|
keyboard::key::Named::ArrowDown => return Some(Message::Pan(0, 50)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block keys with other modifiers (ctrl, alt, meta)
|
||||||
|
if !empty_mods {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
match key {
|
||||||
|
keyboard::Key::Character(c) => match c.as_str() {
|
||||||
|
"q" => Some(Message::Close),
|
||||||
|
"f" => Some(Message::FlipHorizontal),
|
||||||
|
"+" | "=" => Some(Message::ZoomIn),
|
||||||
|
"-" => Some(Message::ZoomOut),
|
||||||
|
"1" => Some(Message::ActualSize),
|
||||||
|
"0" => Some(Message::FitWindow),
|
||||||
|
"r" => Some(Message::RotateCW),
|
||||||
|
" " => Some(Message::ToggleSlideshow),
|
||||||
|
"t" => Some(Message::AdjustSlideshowDelay(1)),
|
||||||
|
"T" => Some(Message::AdjustSlideshowDelay(-1)),
|
||||||
|
"n" => Some(Message::Next),
|
||||||
|
"p" => Some(Message::Prev),
|
||||||
|
"g" => Some(Message::GoToFirst),
|
||||||
|
"G" => Some(Message::GoToLast),
|
||||||
|
"i" => Some(Message::ZoomIn),
|
||||||
|
"o" => Some(Message::ZoomOut),
|
||||||
|
"h" => Some(Message::Prev),
|
||||||
|
"l" => Some(Message::Next),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
keyboard::Key::Named(n) => match n {
|
||||||
|
keyboard::key::Named::ArrowRight => Some(Message::Next),
|
||||||
|
keyboard::key::Named::ArrowLeft => Some(Message::Prev),
|
||||||
|
keyboard::key::Named::ArrowUp => Some(Message::ZoomIn),
|
||||||
|
keyboard::key::Named::ArrowDown => Some(Message::ZoomOut),
|
||||||
|
keyboard::key::Named::Escape => Some(Message::Close),
|
||||||
|
keyboard::key::Named::F11 => Some(Message::ToggleFullscreen),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
5
crates/lychee-core/src/lib.rs
Normal file
5
crates/lychee-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
pub mod app;
|
||||||
|
pub mod keybindings;
|
||||||
|
|
||||||
|
pub use app::{App, Message};
|
||||||
|
pub use keybindings::handle_key_event;
|
||||||
7
crates/lychee-img/Cargo.toml
Normal file
7
crates/lychee-img/Cargo.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
[package]
|
||||||
|
name = "lychee-img"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
image.workspace = true
|
||||||
58
crates/lychee-img/src/lib.rs
Normal file
58
crates/lychee-img/src/lib.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn is_image_file(path: &Path) -> bool {
|
||||||
|
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||||
|
matches!(
|
||||||
|
ext.to_lowercase().as_str(),
|
||||||
|
"png"
|
||||||
|
| "jpg"
|
||||||
|
| "jpeg"
|
||||||
|
| "gif"
|
||||||
|
| "bmp"
|
||||||
|
| "tiff"
|
||||||
|
| "tif"
|
||||||
|
| "webp"
|
||||||
|
| "ico"
|
||||||
|
| "ppm"
|
||||||
|
| "pgm"
|
||||||
|
| "pbm"
|
||||||
|
| "pn"
|
||||||
|
| "svg"
|
||||||
|
| "tga"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_hidden_file(path: &Path) -> bool {
|
||||||
|
path.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.map(|n| n.starts_with('.'))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn collect_paths(paths: Vec<String>) -> Vec<String> {
|
||||||
|
if paths.len() == 1 {
|
||||||
|
let p = Path::new(&paths[0]);
|
||||||
|
if p.is_dir() {
|
||||||
|
let mut result: Vec<String> = std::fs::read_dir(p)
|
||||||
|
.ok()
|
||||||
|
.map(|dir| {
|
||||||
|
dir.filter_map(|e| {
|
||||||
|
let path = e.ok()?.path();
|
||||||
|
if is_image_file(&path) && !is_hidden_file(&path) {
|
||||||
|
Some(path.to_string_lossy().into_owned())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
result.sort();
|
||||||
|
result
|
||||||
|
} else {
|
||||||
|
vec![paths[0].clone()]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
paths
|
||||||
|
}
|
||||||
|
}
|
||||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1775036866,
|
||||||
|
"narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "6201e203d09599479a3b3450ed24fa81537ebc4e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
23
flake.nix
Normal file
23
flake.nix
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
description = "Rust Project Template";
|
||||||
|
inputs.nixpkgs.url = "github:NixOS/nixpkgs?ref=nixos-unstable";
|
||||||
|
|
||||||
|
outputs = {
|
||||||
|
self,
|
||||||
|
nixpkgs,
|
||||||
|
}: let
|
||||||
|
systems = ["x86_64-linux" "aarch64-linux"];
|
||||||
|
forEachSystem = nixpkgs.lib.genAttrs systems;
|
||||||
|
pkgsForEach = nixpkgs.legacyPackages;
|
||||||
|
in {
|
||||||
|
packages = forEachSystem (system: {
|
||||||
|
default = pkgsForEach.${system}.callPackage ./nix/package.nix {};
|
||||||
|
});
|
||||||
|
|
||||||
|
devShells = forEachSystem (system: {
|
||||||
|
default = pkgsForEach.${system}.callPackage ./nix/shell.nix {};
|
||||||
|
});
|
||||||
|
|
||||||
|
hydraJobs = self.packages;
|
||||||
|
};
|
||||||
|
}
|
||||||
8
lychee/Cargo.toml
Normal file
8
lychee/Cargo.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
[package]
|
||||||
|
name = "lychee"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
iced.workspace = true
|
||||||
|
lychee-core.workspace = true
|
||||||
9
lychee/src/main.rs
Normal file
9
lychee/src/main.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
fn main() -> iced::Result {
|
||||||
|
iced::application(
|
||||||
|
lychee_core::App::new,
|
||||||
|
lychee_core::App::update,
|
||||||
|
lychee_core::App::view,
|
||||||
|
)
|
||||||
|
.subscription(lychee_core::App::subscription)
|
||||||
|
.run()
|
||||||
|
}
|
||||||
30
nix/package.nix
Normal file
30
nix/package.nix
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
lib,
|
||||||
|
rustPlatform,
|
||||||
|
}:
|
||||||
|
rustPlatform.buildRustPackage {
|
||||||
|
pname = "lychee";
|
||||||
|
version = "1.0.0";
|
||||||
|
|
||||||
|
src = let
|
||||||
|
fs = lib.fileset;
|
||||||
|
s = ../.;
|
||||||
|
in
|
||||||
|
fs.toSource {
|
||||||
|
root = s;
|
||||||
|
fileset = fs.unions [
|
||||||
|
(s + /crates)
|
||||||
|
(s + /Cargo.lock)
|
||||||
|
(s + /Cargo.toml)
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
cargoLock.lockFile = ../Cargo.lock;
|
||||||
|
enableParallelBuilding = true;
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "Simple, opinionated image viewer for Wayland";
|
||||||
|
maintainers = with lib.maintainers; [NotAShelf];
|
||||||
|
license = lib.licenses.mpl20;
|
||||||
|
};
|
||||||
|
}
|
||||||
33
nix/shell.nix
Normal file
33
nix/shell.nix
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
lib,
|
||||||
|
mkShell,
|
||||||
|
cargo,
|
||||||
|
rustfmt,
|
||||||
|
clippy,
|
||||||
|
taplo,
|
||||||
|
pkg-config,
|
||||||
|
wayland,
|
||||||
|
libxkbcommon,
|
||||||
|
}: let
|
||||||
|
runtimeDeps = [
|
||||||
|
libxkbcommon
|
||||||
|
wayland
|
||||||
|
];
|
||||||
|
in
|
||||||
|
mkShell {
|
||||||
|
name = "rust";
|
||||||
|
|
||||||
|
strictDeps = true;
|
||||||
|
nativeBuildInputs = [
|
||||||
|
pkg-config
|
||||||
|
|
||||||
|
cargo
|
||||||
|
clippy
|
||||||
|
(rustfmt.override {asNightly = true;})
|
||||||
|
taplo
|
||||||
|
];
|
||||||
|
|
||||||
|
buildInputs = runtimeDeps;
|
||||||
|
|
||||||
|
env.LD_LIBRARY_PATH = "$LD_LIBRARY_PATH:${lib.makeLibraryPath runtimeDeps}";
|
||||||
|
}
|
||||||
15
shell.nix
15
shell.nix
|
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
pkgs ? import <nixpkgs> {},
|
|
||||||
lib ? pkgs.lib,
|
|
||||||
}:
|
|
||||||
pkgs.mkShell rec {
|
|
||||||
buildInputs = with pkgs; [
|
|
||||||
libxkbcommon
|
|
||||||
wayland
|
|
||||||
pkg-config
|
|
||||||
];
|
|
||||||
|
|
||||||
shellHook = ''
|
|
||||||
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${lib.makeLibraryPath buildInputs}";
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
75
src/main.rs
75
src/main.rs
|
|
@ -1,75 +0,0 @@
|
||||||
use image::{self, GenericImageView};
|
|
||||||
use minifb::{Key, Window, WindowOptions};
|
|
||||||
use std::path::Path;
|
|
||||||
use std::{env, error::Error};
|
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn Error>> {
|
|
||||||
let args: Vec<String> = env::args().collect();
|
|
||||||
if args.len() < 2 {
|
|
||||||
eprintln!("Please provide a path to the image file.");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let file_path = &args[1];
|
|
||||||
let img = image::open(Path::new(file_path))?;
|
|
||||||
let (img_width, img_height) = img.dimensions();
|
|
||||||
|
|
||||||
let same_size = args.contains(&"--same-size".to_string());
|
|
||||||
|
|
||||||
let img_rgba = img.to_rgba8();
|
|
||||||
|
|
||||||
let mut buffer: Vec<u32> = vec![0; (img_width * img_height) as usize];
|
|
||||||
for (rgba, argb) in img_rgba.chunks(4).zip(buffer.iter_mut()) {
|
|
||||||
let r = rgba[0] as u32;
|
|
||||||
let g = rgba[1] as u32;
|
|
||||||
let b = rgba[2] as u32;
|
|
||||||
let a = rgba[3] as u32;
|
|
||||||
*argb = (a << 24) | (r << 16) | (g << 8) | b;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (window_width, window_height) = if same_size {
|
|
||||||
(img_width as usize, img_height as usize)
|
|
||||||
} else {
|
|
||||||
(1920, 1080)
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut window = Window::new(
|
|
||||||
"lychee",
|
|
||||||
window_width,
|
|
||||||
window_height,
|
|
||||||
WindowOptions {
|
|
||||||
resize: true,
|
|
||||||
borderless: true,
|
|
||||||
..WindowOptions::default()
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
|
|
||||||
window.set_target_fps(60);
|
|
||||||
|
|
||||||
let mut resize_buffer: Vec<u32> = vec![0; window_width * window_height];
|
|
||||||
|
|
||||||
while window.is_open() && !window.is_key_down(Key::Escape) {
|
|
||||||
if same_size {
|
|
||||||
window.update_with_buffer(&buffer, img_width as usize, img_height as usize)?;
|
|
||||||
} else {
|
|
||||||
resize_buffer.fill(0);
|
|
||||||
|
|
||||||
let x_offset = (window_width - img_width as usize) / 2;
|
|
||||||
let y_offset = (window_height - img_height as usize) / 2;
|
|
||||||
|
|
||||||
for (i, &pixel) in buffer.iter().enumerate() {
|
|
||||||
let x = i % img_width as usize;
|
|
||||||
let y = i / img_width as usize;
|
|
||||||
|
|
||||||
if x + x_offset < window_width && y + y_offset < window_height {
|
|
||||||
let index = (y + y_offset) * window_width + (x + x_offset);
|
|
||||||
resize_buffer[index] = pixel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.update_with_buffer(&resize_buffer, window_width, window_height)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue