treewide: rewrite with Iced; migrate to new repository format

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: If2c377d3b866a33a65d85836bc9010756a6a6964
This commit is contained in:
raf 2026-01-14 22:35:55 +03:00
commit c119d7ae26
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
13 changed files with 4646 additions and 949 deletions

4461
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,21 @@
[package]
name = "lychee"
version = "0.1.0"
edition = "2021"
[workspace]
resolver = "3"
members = ["lychee", "crates/*"]
[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"
wayland-client = "0.31"
minifb = "0.28.0"
lychee-cli = { path = "./crates/lychee-cli" }
lychee-img = { path = "./crates/lychee-img" }
lychee-core = { path = "./crates/lychee-core" }
[profile.release]
lto = true
codegen-units = 1
strip = true

View file

@ -0,0 +1,7 @@
[package]
name = "lychee-cli"
version.workspace = true
edition.workspace = true
[dependencies]
clap.workspace = true

View 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,
}

View 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

View file

@ -0,0 +1,186 @@
use std::collections::HashMap;
use iced::{
ContentFit, Element, Subscription, Task, keyboard,
time::{self, milliseconds},
widget::image::viewer,
window,
};
use image::GenericImageView;
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,
Reset,
ToggleFullscreen,
ToggleSlideshow,
SlideshowTick,
AdjustSlideshowDelay(i64),
Close,
}
pub struct App {
window_id: window::Id,
paths: Vec<String>,
current: usize,
image_cache: HashMap<usize, iced::widget::image::Handle>,
fit_to_window: bool,
fullscreen: bool,
slideshow_active: bool,
slideshow_interval: u64,
}
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 app = Self {
window_id,
paths,
current: 0,
image_cache: HashMap::new(),
fit_to_window: true,
fullscreen: args.fullscreen,
slideshow_active: false,
slideshow_interval: args.slideshow,
};
let task = if args.fullscreen {
window::maximize(window_id, true)
} else {
Task::none()
};
(app, task)
}
pub fn view(&self) -> Element<'_, Message> {
match self.image_cache.get(&self.current) {
Some(handle) => {
let v = viewer(handle.clone())
.content_fit(if self.fit_to_window {
ContentFit::Contain
} else {
ContentFit::Cover
})
.padding(20);
v.into()
}
None => iced::widget::text("No image loaded").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);
Subscription::batch(vec![hotkey, close, slideshow])
}
fn preload_adjacent(&mut self) {
for offset in [0isize, -1, 1] {
let idx = (self.current as isize + offset) as usize;
if idx < self.paths.len()
&& !self.image_cache.contains_key(&idx)
&& let Some(handle) = self.load_image(&self.paths[idx])
{
self.image_cache.insert(idx, handle);
}
}
}
fn load_image(&self, path: &str) -> Option<iced::widget::image::Handle> {
let img = ::image::open(path).ok()?;
let (width, height) = img.dimensions();
let raw = img.to_rgba8().into_raw();
Some(iced::widget::image::Handle::from_rgba(width, height, raw))
}
pub fn update(&mut self, message: Message) -> Task<Message> {
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
| Message::ZoomOut
| Message::ActualSize
| Message::FitWindow
| Message::Reset => 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(),
}
}
}

View file

@ -0,0 +1,47 @@
use iced::keyboard;
use crate::Message;
pub fn handle_key_event(
key: &keyboard::Key,
_ctrl: bool,
_shift: bool,
empty_mods: bool,
) -> Option<Message> {
if !empty_mods {
return None;
}
match key {
keyboard::Key::Character(c) => match c.as_str() {
"q" => Some(Message::Close),
"f" => Some(Message::ToggleFullscreen),
"+" | "=" => Some(Message::ZoomIn),
"-" => Some(Message::ZoomOut),
"1" => Some(Message::ActualSize),
"0" => Some(Message::FitWindow),
"r" => Some(Message::Reset),
" " => 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),
_ => None,
},
_ => None,
}
}

View file

@ -0,0 +1,5 @@
pub mod app;
pub mod keybindings;
pub use app::{App, Message};
pub use keybindings::handle_key_event;

View file

@ -0,0 +1,7 @@
[package]
name = "lychee-img"
version.workspace = true
edition.workspace = true
[dependencies]
image.workspace = true

View 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
}
}

8
lychee/Cargo.toml Normal file
View 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
View 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()
}

View file

@ -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(())
}