feat: implement crop functionality with self-contained widget

- Inspired by cosmic-viewer's crop implementation (https://codeberg.org/bhh32/cosmic-viewer)
- Add crop support for all document types (Raster, Vector, Portable)
This commit is contained in:
wfx 2026-01-22 20:40:36 +01:00
parent 9399a008c4
commit 3cf99ad19d
20 changed files with 1042 additions and 103 deletions

5
.zed/settings.json Normal file
View file

@ -0,0 +1,5 @@
// Folder-specific settings
//
// For a full list of overridable settings, and general information on folder-specific settings,
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
{}

View file

@ -14,11 +14,23 @@ license = "GPL-3.0-or-later"
keywords = ["document", "image", "viewer", "pdf", "cosmic"]
categories = ["gui", "multimedia::graphics", "multimedia::images"]
[features]
default = ["image", "vector", "portable"]
image = ["dep:image", "dep:kamadak-exif"]
vector = ["dep:resvg"]
portable = ["dep:poppler", "dep:cairo-rs"]
full = ["image", "vector", "portable"]
[dependencies]
# Error handling
anyhow = "1"
kamadak-exif = "0.5.5"
# Feature-gated dependencies
kamadak-exif = { version = "0.5.5", optional = true }
image = { version = "0.25.9", optional = true }
poppler = { version = "0.4", features = ["render"], optional = true }
cairo-rs = { version = "0.18", features = ["png"], optional = true }
resvg = { version = "0.45", optional = true }
# Async / concurrency
futures-util = "0.3.31"
@ -40,10 +52,6 @@ open = "5.3.2"
rust-embed = "8.8.0"
dirs = "5.0"
sha2 = "0.10"
image = "0.25.9"
poppler = { version = "0.4", features = ["render"] }
cairo-rs = { version = "0.18", features = ["png"] }
resvg = "0.45"
clap = { version = "4.5.54", features = ["derive"] }
env_logger = "0.11.8"
wallpaper = "3.2"

View file

@ -42,14 +42,14 @@ fn cache_key(file_path: &Path, page: usize) -> Option<String> {
hasher.update(page.to_le_bytes());
let hash = hasher.finalize();
Some(format!("{:x}", hash))
Some(format!("{hash:x}"))
}
/// Get the full path for a cached thumbnail.
fn thumbnail_path(file_path: &Path, page: usize) -> Option<PathBuf> {
let dir = cache_dir()?;
let key = cache_key(file_path, page)?;
Some(dir.join(format!("{}.{}", key, THUMBNAIL_EXT)))
Some(dir.join(format!("{key}.{THUMBNAIL_EXT}")))
}
/// Load a thumbnail from disk cache.
@ -81,7 +81,7 @@ pub fn load_thumbnail(file_path: &Path, page: usize) -> Option<ImageHandle> {
pub fn save_thumbnail(file_path: &Path, page: usize, image: &DynamicImage) -> Option<()> {
let dir = ensure_cache_dir()?;
let key = cache_key(file_path, page)?;
let cache_path = dir.join(format!("{}.{}", key, THUMBNAIL_EXT));
let cache_path = dir.join(format!("{key}.{THUMBNAIL_EXT}"));
log::debug!(
"Saving thumbnail to cache: file={}, page={}, path={}",
@ -98,7 +98,7 @@ pub fn save_thumbnail(file_path: &Path, page: usize, image: &DynamicImage) -> Op
image::ImageFormat::Png,
);
match res {
Ok(_) => {
Ok(()) => {
log::debug!(
"Thumbnail cached successfully: file={} page={}",
file_path.display(),
@ -121,17 +121,16 @@ pub fn save_thumbnail(file_path: &Path, page: usize, image: &DynamicImage) -> Op
/// Check if a thumbnail exists in cache.
#[allow(dead_code)]
pub fn has_thumbnail(file_path: &Path, page: usize) -> bool {
thumbnail_path(file_path, page)
.map(|p| p.exists())
.unwrap_or(false)
thumbnail_path(file_path, page).is_some_and(|p| p.exists())
}
/// Clear all cached thumbnails.
#[allow(dead_code)]
pub fn clear_cache() -> std::io::Result<()> {
if let Some(dir) = cache_dir()
&& dir.exists() {
fs::remove_dir_all(&dir)?;
}
&& dir.exists()
{
fs::remove_dir_all(&dir)?;
}
Ok(())
}

View file

@ -21,7 +21,7 @@ use crate::app::model::{AppModel, ViewMode};
/// based on enabled codecs (e.g. default-formats).
pub fn open_document(path: &Path) -> anyhow::Result<DocumentContent> {
let kind = DocumentKind::from_path(path)
.ok_or_else(|| anyhow!("Unsupported document type: {:?}", path))?;
.ok_or_else(|| anyhow!("Unsupported document type: {}", path.display()))?;
let content = match kind {
DocumentKind::Raster => {
@ -46,11 +46,11 @@ pub fn open_document(path: &Path) -> anyhow::Result<DocumentContent> {
/// If `path` is a directory, this will collect supported documents inside it,
/// open the first one, and initialize navigation state. If it is a file, the
/// file is opened directly and the surrounding folder is scanned.
pub fn open_initial_path(model: &mut AppModel, path: PathBuf) {
pub fn open_initial_path(model: &mut AppModel, path: &PathBuf) {
if path.is_dir() {
open_from_directory(model, &path);
open_from_directory(model, path);
} else {
open_single_file(model, &path);
open_single_file(model, path);
}
}
@ -80,9 +80,10 @@ pub fn open_single_file(model: &mut AppModel, path: &Path) {
// Refresh folder listing based on parent directory.
if model.document.is_some()
&& let Some(parent) = path.parent() {
refresh_folder_entries(model, parent, path);
}
&& let Some(parent) = path.parent()
{
refresh_folder_entries(model, parent, path);
}
}
/// Load a document into the model, resetting view state.
@ -200,3 +201,51 @@ pub fn file_size(path: &Path) -> u64 {
pub fn read_file_bytes(path: &Path) -> Option<Vec<u8>> {
fs::read(path).ok()
}
// ---------------------------------------------------------------------------
// Crop operations
// ---------------------------------------------------------------------------
/// Save a cropped version of the document with coordinates in filename.
///
/// Format: "original_NAME_X_Y.EXT"
/// Example: "image.png" → "image_100_200.png"
pub fn save_crop_as(
doc: &DocumentContent,
original_path: &Path,
x: u32,
y: u32,
width: u32,
height: u32,
) -> Result<PathBuf, String> {
let stem = original_path
.file_stem()
.ok_or_else(|| "Invalid path".to_string())?
.to_string_lossy();
let ext = original_path
.extension()
.ok_or_else(|| "No extension".to_string())?
.to_string_lossy();
let new_filename = format!("{stem}_{x}_{y}");
let new_path = original_path
.with_file_name(&new_filename)
.with_extension(ext.as_ref());
match doc {
DocumentContent::Raster(raster_doc) => {
let cropped_image = raster_doc
.crop_to_image(x, y, width, height)
.map_err(|e| e.to_string())?;
cropped_image.save(&new_path).map_err(|e| e.to_string())?;
}
DocumentContent::Vector(_) => {
return Err("Crop not supported for vector documents".to_string());
}
DocumentContent::Portable(_) => {
return Err("Crop not supported for PDF documents".to_string());
}
}
Ok(new_path)
}

View file

@ -38,14 +38,19 @@ impl BasicMeta {
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
#[allow(clippy::cast_precision_loss)]
if self.file_size >= GB {
format!("{:.2} GB", self.file_size as f64 / GB as f64)
let size_gb = self.file_size as f64 / GB as f64;
format!("{size_gb:.2} GB")
} else if self.file_size >= MB {
format!("{:.2} MB", self.file_size as f64 / MB as f64)
let size_mb = self.file_size as f64 / MB as f64;
format!("{size_mb:.2} MB")
} else if self.file_size >= KB {
format!("{:.1} KB", self.file_size as f64 / KB as f64)
let size_kb = self.file_size as f64 / KB as f64;
format!("{size_kb:.1} KB")
} else {
format!("{} B", self.file_size)
let size = self.file_size;
format!("{size} B")
}
}
@ -77,7 +82,7 @@ impl ExifMeta {
if model.starts_with(make) {
Some(model.clone())
} else {
Some(format!("{} {}", make, model))
Some(format!("{make} {model}"))
}
}
(Some(make), None) => Some(make.clone()),
@ -89,7 +94,7 @@ impl ExifMeta {
/// Format GPS coordinates for display.
pub fn gps_display(&self) -> Option<String> {
match (self.gps_latitude, self.gps_longitude) {
(Some(lat), Some(lon)) => Some(format!("{:.5}, {:.5}", lat, lon)),
(Some(lat), Some(lon)) => Some(format!("{lat:.5}, {lon:.5}")),
_ => None,
}
}
@ -165,9 +170,10 @@ fn extract_exif_from_bytes(data: &[u8]) -> Option<ExifMeta> {
}
if let Some(field) = exif.get_field(Tag::PhotographicSensitivity, In::PRIMARY)
&& let Value::Short(ref vals) = field.value
&& let Some(&iso) = vals.first() {
meta.iso = Some(iso as u32);
}
&& let Some(&iso) = vals.first()
{
meta.iso = Some(u32::from(iso));
}
if let Some(field) = exif.get_field(Tag::FocalLength, In::PRIMARY) {
meta.focal_length = Some(field.display_value().to_string());
}
@ -210,7 +216,10 @@ fn extract_gps_coord(exif: &exif::Exif, coord_tag: Tag, ref_tag: Tag) -> Option<
/// Determine color type string from DynamicImage.
fn color_type_string(img: &DynamicImage) -> String {
use image::DynamicImage::*;
use image::DynamicImage::{
ImageLuma8, ImageLumaA8, ImageRgb8, ImageRgba8, ImageLuma16, ImageLumaA16, ImageRgb16,
ImageRgba16, ImageRgb32F, ImageRgba32F,
};
match img {
ImageLuma8(_) => "Grayscale 8-bit".to_string(),
ImageLumaA8(_) => "Grayscale+Alpha 8-bit".to_string(),
@ -230,8 +239,7 @@ fn color_type_string(img: &DynamicImage) -> String {
fn format_from_extension(path: &Path) -> String {
path.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_uppercase())
.unwrap_or_else(|| "Unknown".to_string())
.map_or_else(|| "Unknown".to_string(), str::to_uppercase)
}
// ---------------------------------------------------------------------------
@ -259,7 +267,7 @@ pub fn build_vector_meta(path: &Path, width: u32, height: u32) -> DocumentMeta {
/// Build metadata for a portable document.
pub fn build_portable_meta(path: &Path, width: u32, height: u32, page_count: u32) -> DocumentMeta {
let format = format!("PDF ({} pages)", page_count);
let format = format!("PDF ({page_count} pages)");
let basic = extract_basic_meta(path, width, height, &format, "Rendered".to_string());
DocumentMeta { basic, exif: None }

View file

@ -6,18 +6,26 @@
pub mod cache;
pub mod file;
pub mod meta;
pub mod portable;
pub mod raster;
pub mod utils;
#[cfg(feature = "portable")]
pub mod portable;
#[cfg(feature = "image")]
pub mod raster;
#[cfg(feature = "vector")]
pub mod vector;
use cosmic::iced_renderer::graphics::image::image_rs::ImageFormat as CosmicImageFormat;
#[cfg(feature = "image")]
use image::GenericImageView;
use std::fmt;
use std::path::Path;
#[cfg(feature = "portable")]
use self::portable::PortableDocument;
#[cfg(feature = "image")]
use self::raster::RasterDocument;
#[cfg(feature = "vector")]
use self::vector::VectorDocument;
// ============================================================================
@ -96,8 +104,6 @@ pub struct TransformState {
pub flip_v: bool,
}
/// Output of a render operation.
///
/// Used as return type for the `Renderable::render()` trait method.
@ -360,6 +366,18 @@ impl DocumentContent {
self.flip(FlipDirection::Vertical);
}
/// Crop the document to the specified rectangle.
///
/// Only supported for raster images. Returns an error for vector/PDF documents.
/// Coordinates are in pixels relative to current image dimensions.
pub fn crop(&mut self, x: u32, y: u32, width: u32, height: u32) -> DocResult<()> {
match self {
Self::Raster(doc) => doc.crop(x, y, width, height),
Self::Vector(_) => Err(anyhow::anyhow!("Crop not supported for vector documents")),
Self::Portable(_) => Err(anyhow::anyhow!("Crop not supported for PDF documents")),
}
}
/// Get document kind.
///
/// Reserved for future use (format-specific optimizations, statistics).
@ -446,7 +464,9 @@ impl DocumentContent {
/// Currently unused - thumbnails are generated incrementally via `generate_thumbnail_page()`.
#[allow(dead_code)]
pub fn generate_thumbnails(&mut self) {
if let Self::Portable(doc) = self { doc.generate_all_thumbnails() }
if let Self::Portable(doc) = self {
doc.generate_all_thumbnails()
}
}
/// Get current image handle for display.

View file

@ -40,7 +40,7 @@ impl PortableDocument {
/// Open a PDF document and render the first page.
pub fn open(path: &Path) -> anyhow::Result<Self> {
let document = PopplerDocument::new_from_file(path, None)
.map_err(|e| anyhow::anyhow!("Failed to parse PDF: {}", e))?;
.map_err(|e| anyhow::anyhow!("Failed to parse PDF: {e}"))?;
let num_pages = document.get_n_pages();
if num_pages == 0 {
@ -107,14 +107,13 @@ impl PortableDocument {
return handle;
}
match Self::render_page_at_scale(&self.document, page, Rotation::None, PDF_THUMBNAIL_SIZE)
{
match Self::render_page_at_scale(&self.document, page, Rotation::None, PDF_THUMBNAIL_SIZE) {
Ok(img) => {
let _ = cache::save_thumbnail(&self.source_path, page, &img);
super::create_image_handle_from_image(&img)
}
Err(e) => {
log::warn!("Failed to generate thumbnail for page {}: {}", page, e);
log::warn!("Failed to generate thumbnail for page {page}: {e}");
ImageHandle::from_rgba(1, 1, vec![0, 0, 0, 0])
}
}
@ -138,7 +137,7 @@ impl PortableDocument {
) -> anyhow::Result<DynamicImage> {
let page = document
.get_page(page_index)
.ok_or_else(|| anyhow::anyhow!("Failed to get page {}", page_index))?;
.ok_or_else(|| anyhow::anyhow!("Failed to get page {page_index}"))?;
let (page_width, page_height) = page.get_size();
let rotation_degrees = rotation.to_degrees();
@ -155,10 +154,10 @@ impl PortableDocument {
let scaled_height = (height * scale) as i32;
let surface = ImageSurface::create(Format::ARgb32, scaled_width, scaled_height)
.map_err(|e| anyhow::anyhow!("Failed to create Cairo surface: {}", e))?;
.map_err(|e| anyhow::anyhow!("Failed to create Cairo surface: {e}"))?;
let context = Context::new(&surface)
.map_err(|e| anyhow::anyhow!("Failed to create Cairo context: {}", e))?;
.map_err(|e| anyhow::anyhow!("Failed to create Cairo context: {e}"))?;
// Fill with white background.
context.set_source_rgb(1.0, 1.0, 1.0);
@ -182,13 +181,13 @@ impl PortableDocument {
let mut png_data: Vec<u8> = Vec::new();
surface
.write_to_png(&mut png_data)
.map_err(|e| anyhow::anyhow!("Failed to write PNG: {}", e))?;
.map_err(|e| anyhow::anyhow!("Failed to write PNG: {e}"))?;
let image = ImageReader::new(Cursor::new(png_data))
.with_guessed_format()
.map_err(|e| anyhow::anyhow!("Failed to read PNG format: {}", e))?
.map_err(|e| anyhow::anyhow!("Failed to read PNG format: {e}"))?
.decode()
.map_err(|e| anyhow::anyhow!("Failed to decode PNG: {}", e))?;
.map_err(|e| anyhow::anyhow!("Failed to decode PNG: {e}"))?;
Ok(image)
}
@ -208,7 +207,7 @@ impl PortableDocument {
self.refresh_handle();
}
Err(e) => {
log::error!("Failed to render PDF page: {}", e);
log::error!("Failed to render PDF page: {e}");
}
}
}

View file

@ -62,6 +62,54 @@ impl RasterDocument {
pub fn extract_meta(&self, path: &Path) -> super::meta::DocumentMeta {
super::meta::build_raster_meta(path, &self.document, self.native_width, self.native_height)
}
/// Crop the image to the specified rectangle.
///
/// Coordinates are in pixels relative to the current image dimensions.
/// Returns an error if the rectangle is out of bounds.
pub fn crop(&mut self, x: u32, y: u32, width: u32, height: u32) -> DocResult<()> {
let (img_width, img_height) = self.document.dimensions();
if x + width > img_width || y + height > img_height {
return Err(anyhow::anyhow!(
"Crop rectangle out of bounds: {width}x{height} at ({x}, {y}) exceeds image size {img_width}x{img_height}"
));
}
let cropped = imageops::crop_imm(&self.document, x, y, width, height).to_image();
self.document = DynamicImage::ImageRgba8(cropped);
self.native_width = width;
self.native_height = height;
self.transform = TransformState::default();
self.refresh_handle();
Ok(())
}
/// Crop the image to the specified rectangle and return as DynamicImage.
///
/// This does NOT modify the document - it's used for exporting cropped images.
pub fn crop_to_image(
&self,
x: u32,
y: u32,
width: u32,
height: u32,
) -> DocResult<DynamicImage> {
let (img_width, img_height) = self.document.dimensions();
if x + width > img_width || y + height > img_height {
return Err(anyhow::anyhow!(
"Crop rectangle out of bounds: {width}x{height} at ({x}, {y}) exceeds image size {img_width}x{img_height}"
));
}
let cropped = imageops::crop_imm(&self.document, x, y, width, height).to_image();
Ok(DynamicImage::ImageRgba8(cropped))
}
}
// ============================================================================

View file

@ -22,15 +22,12 @@ pub fn set_as_wallpaper(path: &Path) {
}
};
let path_str = match abs_path.to_str() {
Some(s) => s,
None => {
log::error!("Invalid UTF-8 in path: {}", abs_path.display());
return;
}
let Some(path_str) = abs_path.to_str() else {
log::error!("Invalid UTF-8 in path: {}", abs_path.display());
return;
};
log::info!("Attempting to set wallpaper: {}", path_str);
log::info!("Attempting to set wallpaper: {path_str}");
// Method 1: Try COSMIC Desktop (direct config file modification).
if try_cosmic_wallpaper(path_str) {
@ -69,23 +66,22 @@ fn try_cosmic_wallpaper(path_str: &str) -> bool {
let config_content = format!(
r#"(
output: "all",
source: Path("{}"),
source: Path("{path_str}"),
filter_by_theme: true,
rotation_frequency: 300,
filter_method: Lanczos,
scaling_mode: Zoom,
sampling_method: Alphanumeric,
)"#,
path_str
)"#
);
match std::fs::write(&cosmic_config, config_content) {
Ok(_) => {
Ok(()) => {
log::info!("Wallpaper set via COSMIC config");
true
}
Err(e) => {
log::warn!("Failed to write COSMIC config: {}", e);
log::warn!("Failed to write COSMIC config: {e}");
false
}
}
@ -94,12 +90,12 @@ fn try_cosmic_wallpaper(path_str: &str) -> bool {
/// Try setting wallpaper via wallpaper crate.
fn try_wallpaper_crate(path_str: &str) -> bool {
match wallpaper::set_from_path(path_str) {
Ok(_) => {
Ok(()) => {
log::info!("Wallpaper set via wallpaper crate");
true
}
Err(e) => {
log::warn!("wallpaper crate failed: {}", e);
log::warn!("wallpaper crate failed: {e}");
false
}
}
@ -107,7 +103,7 @@ fn try_wallpaper_crate(path_str: &str) -> bool {
/// Try setting wallpaper via GNOME gsettings.
fn try_gsettings_wallpaper(path_str: &str) -> bool {
let uri = format!("file://{}", path_str);
let uri = format!("file://{path_str}");
let output = match std::process::Command::new("gsettings")
.args(["set", "org.gnome.desktop.background", "picture-uri", &uri])
@ -115,7 +111,7 @@ fn try_gsettings_wallpaper(path_str: &str) -> bool {
{
Ok(o) => o,
Err(e) => {
log::warn!("gsettings command failed: {}", e);
log::warn!("gsettings command failed: {e}");
return false;
}
};
@ -145,15 +141,12 @@ fn try_gsettings_wallpaper(path_str: &str) -> bool {
/// Try setting wallpaper via feh.
fn try_feh_wallpaper(path_str: &str) -> bool {
let output = match std::process::Command::new("feh")
let Ok(output) = std::process::Command::new("feh")
.args(["--bg-scale", path_str])
.output()
{
Ok(o) => o,
Err(_) => {
log::warn!("feh not available");
return false;
}
else {
log::warn!("feh not available");
return false;
};
if output.status.success() {

View file

@ -55,7 +55,7 @@ impl VectorDocument {
// Render at native scale (1.0).
let (rendered, width, height) =
render_document(&document, native_width, native_height, 1.0, &transform)?;
render_document(&document, native_width, native_height, 1.0, transform)?;
let handle = super::create_image_handle_from_image(&rendered);
Ok(Self {
@ -90,7 +90,7 @@ impl VectorDocument {
self.native_width,
self.native_height,
scale,
&self.transform,
self.transform,
) {
Ok((rendered, width, height)) => {
self.current_scale = scale;
@ -101,7 +101,7 @@ impl VectorDocument {
true
}
Err(e) => {
log::error!("Failed to re-render SVG at scale {}: {}", scale, e);
log::error!("Failed to re-render SVG at scale {scale}: {e}");
false
}
}
@ -114,7 +114,7 @@ impl VectorDocument {
self.native_width,
self.native_height,
self.current_scale,
&self.transform,
self.transform,
) {
self.rendered = rendered;
self.width = width;
@ -178,12 +178,12 @@ fn render_document(
native_width: u32,
native_height: u32,
scale: f64,
transform: &TransformState,
transform: TransformState,
) -> anyhow::Result<(DynamicImage, u32, u32)> {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let width = (((native_width as f64) * scale).ceil() as u32).max(MIN_PIXMAP_SIZE);
let width = ((f64::from(native_width) * scale).ceil() as u32).max(MIN_PIXMAP_SIZE);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let height = (((native_height as f64) * scale).ceil() as u32).max(MIN_PIXMAP_SIZE);
let height = ((f64::from(native_height) * scale).ceil() as u32).max(MIN_PIXMAP_SIZE);
let mut pixmap =
Pixmap::new(width, height).ok_or_else(|| anyhow::anyhow!("Failed to create pixmap"))?;

View file

@ -6,6 +6,7 @@
use std::path::PathBuf;
use crate::app::ContextPage;
use crate::app::view::crop::DragHandle;
#[derive(Debug, Clone)]
pub enum AppMessage {
@ -45,6 +46,21 @@ pub enum AppMessage {
ToggleCropMode,
ToggleScaleMode,
// Crop operations.
StartCrop,
CancelCrop,
ApplyCrop,
CropDragStart {
x: f32,
y: f32,
handle: DragHandle,
},
CropDragMove {
x: f32,
y: f32,
},
CropDragEnd,
// Panels.
ToggleContextPage(ContextPage),
ToggleNavBar,
@ -53,6 +69,9 @@ pub enum AppMessage {
#[allow(dead_code)]
RefreshMetadata,
// Save operations.
SaveAs,
// Wallpaper.
SetAsWallpaper,

View file

@ -91,7 +91,7 @@ impl cosmic::Application for Noctua {
});
if let Some(path) = initial_path {
document::file::open_initial_path(&mut model, path);
document::file::open_initial_path(&mut model, &path);
}
// Initialize nav bar model (required for COSMIC to show toggle icon).
@ -221,6 +221,7 @@ impl Noctua {
/// Map raw key presses + modifiers into high-level application messages.
fn handle_key_press(key: Key, modifiers: Modifiers) -> Option<AppMessage> {
eprintln!("DEBUG KEY: key={:?} modifiers={:?}", key, modifiers);
use AppMessage::*;
// Handle Ctrl + arrow keys for panning.
@ -262,9 +263,16 @@ fn handle_key_press(key: Key, modifiers: Modifiers) -> Option<AppMessage> {
Key::Character(ch) if ch.eq_ignore_ascii_case("f") => Some(ZoomFit),
// Tool modes.
Key::Character(ch) if ch.eq_ignore_ascii_case("c") => Some(ToggleCropMode),
Key::Character(ch) if ch.eq_ignore_ascii_case("c") => {
eprintln!("DEBUG MATCH: ToggleCropMode");
Some(ToggleCropMode)
}
Key::Character(ch) if ch.eq_ignore_ascii_case("s") => Some(ToggleScaleMode),
// Crop mode actions (Enter/Escape handled via key press, validated in update).
Key::Named(Named::Enter) => Some(AppMessage::ApplyCrop),
Key::Named(Named::Escape) => Some(AppMessage::CancelCrop),
// Reset pan.
Key::Character("0") => Some(PanReset),

View file

@ -7,6 +7,7 @@ use std::path::PathBuf;
use crate::app::document::meta::DocumentMeta;
use crate::app::document::DocumentContent;
use crate::app::view::crop::CropSelection;
use crate::config::AppConfig;
// =============================================================================
@ -58,6 +59,7 @@ pub struct AppModel {
// Tools.
pub tool_mode: ToolMode,
pub crop_selection: CropSelection,
// UI state.
pub error: Option<String>,
@ -76,6 +78,7 @@ impl AppModel {
pan_x: 0.0,
pan_y: 0.0,
tool_mode: ToolMode::None,
crop_selection: CropSelection::default(),
error: None,
tick: 0,
}

View file

@ -40,22 +40,24 @@ pub fn update(model: &mut AppModel, msg: &AppMessage, config: &AppConfig) -> Upd
AppMessage::GotoPage(page) => {
if let Some(doc) = &mut model.document
&& let Err(e) = doc.go_to_page(*page) {
log::error!("Failed to navigate to page {}: {}", page, e);
}
&& let Err(e) = doc.go_to_page(*page)
{
log::error!("Failed to navigate to page {page}: {e}");
}
}
// ---- Thumbnail generation -------------------------------------------------
AppMessage::GenerateThumbnailPage(page) => {
if let Some(doc) = &mut model.document
&& let Some(next_page) = doc.generate_thumbnail_page(*page) {
return UpdateResult::Task(Task::batch([
Task::future(async move {
Action::App(AppMessage::GenerateThumbnailPage(next_page))
}),
Task::done(Action::App(AppMessage::RefreshView)),
]));
}
&& let Some(next_page) = doc.generate_thumbnail_page(*page)
{
return UpdateResult::Task(Task::batch([
Task::future(async move {
Action::App(AppMessage::GenerateThumbnailPage(next_page))
}),
Task::done(Action::App(AppMessage::RefreshView)),
]));
}
}
AppMessage::RefreshView => {
@ -110,6 +112,10 @@ pub fn update(model: &mut AppModel, msg: &AppMessage, config: &AppConfig) -> Upd
// ---- Tool modes ----------------------------------------------------------
AppMessage::ToggleCropMode => {
eprintln!(
"DEBUG: ToggleCropMode received, current tool_mode={:?}",
model.tool_mode
);
model.tool_mode = if model.tool_mode == ToolMode::Crop {
ToolMode::None
} else {
@ -124,6 +130,68 @@ pub fn update(model: &mut AppModel, msg: &AppMessage, config: &AppConfig) -> Upd
};
}
// ---- Crop operations -----------------------------------------------------
AppMessage::StartCrop => {
if model.document.is_some() {
model.tool_mode = ToolMode::Crop;
model.crop_selection.reset();
}
}
AppMessage::CancelCrop => {
if model.tool_mode == ToolMode::Crop {
model.tool_mode = ToolMode::None;
model.crop_selection.reset();
}
}
AppMessage::ApplyCrop => {
if model.tool_mode == ToolMode::Crop {
if let Some((x, y, width, height)) = model.crop_selection.as_pixel_rect() {
if let Some(path) = &model.current_path {
if let Some(doc) = &model.document {
match document::file::save_crop_as(doc, path, x, y, width, height) {
Ok(new_path) => {
document::file::open_single_file(model, &new_path);
model.tool_mode = ToolMode::None;
model.crop_selection.reset();
}
Err(e) => {
model.set_error(format!("Crop save failed: {e}"));
}
}
}
}
}
}
}
AppMessage::CropDragStart { x, y, handle } => {
if model.tool_mode == ToolMode::Crop {
if *handle == super::view::crop::DragHandle::None {
model.crop_selection.start_new_selection(*x, *y);
} else {
model.crop_selection.start_handle_drag(*handle, *x, *y);
}
}
}
AppMessage::CropDragMove { x, y } => {
if model.tool_mode == ToolMode::Crop {
if let Some(doc) = &model.document {
let (w, h) = doc.dimensions();
#[allow(clippy::cast_precision_loss)]
model.crop_selection.update_drag(*x, *y, w as f32, h as f32);
}
}
}
AppMessage::CropDragEnd => {
if model.tool_mode == ToolMode::Crop {
model.crop_selection.end_drag();
}
}
// ---- Save operations -----------------------------------------------------
AppMessage::SaveAs => {
save_as(model);
}
// ---- Document transformations --------------------------------------------
AppMessage::FlipHorizontal => {
if let Some(doc) = &mut model.document {
@ -216,3 +284,9 @@ fn set_as_wallpaper(model: &mut AppModel) {
};
document::set_as_wallpaper(path);
}
fn save_as(model: &mut AppModel) {
// TODO: Implement file dialog for save path
// For now, show error that this needs UI integration
model.set_error("Save As: File dialog not yet implemented");
}

View file

@ -4,11 +4,13 @@
// Render the center canvas area with the current document.
use cosmic::iced::{ContentFit, Length};
use cosmic::iced_widget::stack;
use cosmic::widget::{container, text};
use cosmic::Element;
use super::crop::crop_overlay;
use super::image_viewer::Viewer;
use crate::app::model::ViewMode;
use crate::app::model::{ToolMode, ViewMode};
use crate::app::{AppMessage, AppModel};
use crate::config::AppConfig;
use crate::fl;
@ -17,16 +19,14 @@ use crate::fl;
pub fn view<'a>(model: &'a AppModel, config: &'a AppConfig) -> Element<'a, AppMessage> {
if let Some(doc) = &model.document {
let handle = doc.handle();
let (width, height) = doc.dimensions();
// Determine zoom scale and content fit based on view mode
let (scale, content_fit) = match model.view_mode {
ViewMode::Fit => (1.0, ContentFit::Contain),
ViewMode::ActualSize => (1.0, ContentFit::None),
ViewMode::Custom(z) => (z, ContentFit::None),
};
// Use our forked viewer with external state control
// scale_step is (scale_step - 1.0) because viewer uses additive step
let img_viewer = Viewer::new(handle)
.with_state(scale, model.pan_x, model.pan_y)
.on_state_change(|scale, offset_x, offset_y| AppMessage::ViewerStateChanged {
@ -41,12 +41,25 @@ pub fn view<'a>(model: &'a AppModel, config: &'a AppConfig) -> Element<'a, AppMe
.max_scale(config.max_scale)
.scale_step(config.scale_step - 1.0);
container(img_viewer)
.width(Length::Fill)
.height(Length::Fill)
.into()
if model.tool_mode == ToolMode::Crop {
let overlay = crop_overlay(
width,
height,
&model.crop_selection,
config.crop_show_grid,
scale,
model.pan_x,
model.pan_y,
);
stack![overlay, img_viewer].into()
} else {
container(img_viewer)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
} else {
// Placeholder when no document is loaded
container(text(fl!("no-document")))
.width(Length::Fill)
.height(Length::Fill)

11
src/app/view/crop/mod.rs Normal file
View file

@ -0,0 +1,11 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/app/view/crop/mod.rs
//
// Crop selection module: overlay widget and selection state.
// Inspired by cosmic-viewer (https://codeberg.org/bhh by Bryan Hyland
mod selection;
mod overlay;
pub use selection::{CropSelection, DragHandle};
pub use overlay::crop_overlay;

View file

@ -0,0 +1,493 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/app/view/crop/overlay.rs
//
// Crop overlay widget with selection UI (overlay, border, handles, grid).
// Inspired by cosmic-viewer (https://codeberg.org/bhh by Bryan Hyland
use crate::app::view::crop::selection::{CropSelection, DragHandle};
use cosmic::{
Element, Renderer,
iced::{
Color, Length, Point, Rectangle, Size,
advanced::{
Clipboard, Layout, Shell, Widget,
layout::{Limits, Node},
renderer::{Quad, Renderer as QuadRenderer},
widget::Tree,
},
event::{Event, Status},
mouse::{self, Button, Cursor},
},
};
const HANDLE_SIZE: f32 = 14.0;
const HANDLE_HIT_SIZE: f32 = 28.0;
const OVERLAY_COLOR: Color = Color::from_rgba(0.0, 0.0, 0.0, 0.5);
const HANDLE_COLOR: Color = Color::WHITE;
const BORDER_COLOR: Color = Color::WHITE;
const BORDER_WIDTH: f32 = 2.0;
const GRID_COLOR: Color = Color::from_rgba(1.0, 1.0, 1.0, 0.8);
const GRID_WIDTH: f32 = 1.0;
pub struct CropOverlay {
img_width: u32,
img_height: u32,
selection: CropSelection,
show_grid: bool,
scale: f32,
pan_x: f32,
pan_y: f32,
}
impl CropOverlay {
pub fn new(
img_width: u32,
img_height: u32,
selection: &CropSelection,
show_grid: bool,
scale: f32,
pan_x: f32,
pan_y: f32,
) -> Self {
Self {
img_width,
img_height,
selection: selection.clone(),
show_grid,
scale,
pan_x,
pan_y,
}
}
fn get_base_scale(&self, bounds: &Rectangle) -> f32 {
let scale_x = bounds.width / self.img_width as f32;
let scale_y = bounds.height / self.img_height as f32;
scale_x.min(scale_y) // Fit to bounds (wie bei ViewMode::Fit)
}
fn get_effective_scale(&self, bounds: &Rectangle) -> f32 {
if self.scale > 0.0 {
self.scale
} else {
self.get_base_scale(bounds)
}
}
fn screen_to_image(&self, bounds: &Rectangle, point: Point) -> (f32, f32) {
let effective_scale = self.get_effective_scale(bounds);
// Berechne zentrierte Position des Images mit aktuellem Zoom
let img_screen_width = self.img_width as f32 * effective_scale;
let img_screen_height = self.img_height as f32 * effective_scale;
let offset_x = (bounds.width - img_screen_width) / 2.0 - self.pan_x;
let offset_y = (bounds.height - img_screen_height) / 2.0 - self.pan_y;
let x = ((point.x - bounds.x - offset_x) / effective_scale)
.max(0.0)
.min(self.img_width as f32);
let y = ((point.y - bounds.y - offset_y) / effective_scale)
.max(0.0)
.min(self.img_height as f32);
(x, y)
}
fn image_to_screen(&self, bounds: &Rectangle, img_x: f32, img_y: f32) -> Point {
let effective_scale = self.get_effective_scale(bounds);
// Berechne zentrierte Position des Images mit aktuellem Zoom
let img_screen_width = self.img_width as f32 * effective_scale;
let img_screen_height = self.img_height as f32 * effective_scale;
let offset_x = (bounds.width - img_screen_width) / 2.0 - self.pan_x;
let offset_y = (bounds.height - img_screen_height) / 2.0 - self.pan_y;
Point::new(
bounds.x + offset_x + img_x * effective_scale,
bounds.y + offset_y + img_y * effective_scale,
)
}
fn hit_test_handle(&self, bounds: &Rectangle, point: Point) -> DragHandle {
let Some((rx, ry, rw, rh)) = self.selection.region else {
return DragHandle::None;
};
let top_left = self.image_to_screen(bounds, rx, ry);
let top_right = self.image_to_screen(bounds, rx + rw, ry);
let bottom_left = self.image_to_screen(bounds, rx, ry + rh);
let bottom_right = self.image_to_screen(bounds, rx + rw, ry + rh);
if self.point_in_handle(point, top_left) {
return DragHandle::TopLeft;
}
if self.point_in_handle(point, top_right) {
return DragHandle::TopRight;
}
if self.point_in_handle(point, bottom_left) {
return DragHandle::BottomLeft;
}
if self.point_in_handle(point, bottom_right) {
return DragHandle::BottomRight;
}
let mid_top = self.image_to_screen(bounds, rx + rw / 2.0, ry);
let mid_bottom = self.image_to_screen(bounds, rx + rw / 2.0, ry + rh);
let mid_left = self.image_to_screen(bounds, rx, ry + rh / 2.0);
let mid_right = self.image_to_screen(bounds, rx + rw, ry + rh / 2.0);
if self.point_in_handle(point, mid_top) {
return DragHandle::Top;
}
if self.point_in_handle(point, mid_bottom) {
return DragHandle::Bottom;
}
if self.point_in_handle(point, mid_left) {
return DragHandle::Left;
}
if self.point_in_handle(point, mid_right) {
return DragHandle::Right;
}
let selection_rect = Rectangle::new(
top_left,
Size::new(bottom_right.x - top_left.x, bottom_right.y - top_left.y),
);
if selection_rect.contains(point) {
return DragHandle::Move;
}
DragHandle::None
}
fn point_in_handle(&self, point: Point, handle_center: Point) -> bool {
let half = HANDLE_HIT_SIZE / 2.0;
point.x >= handle_center.x - half
&& point.x <= handle_center.x + half
&& point.y >= handle_center.y - half
&& point.y <= handle_center.y + half
}
fn cursor_for_handle(&self, handle: DragHandle) -> mouse::Interaction {
match handle {
DragHandle::None => mouse::Interaction::Crosshair,
DragHandle::TopLeft | DragHandle::BottomRight => {
mouse::Interaction::ResizingDiagonallyDown
}
DragHandle::TopRight | DragHandle::BottomLeft => {
mouse::Interaction::ResizingDiagonallyUp
}
DragHandle::Top | DragHandle::Bottom => mouse::Interaction::ResizingVertically,
DragHandle::Left | DragHandle::Right => mouse::Interaction::ResizingHorizontally,
DragHandle::Move => mouse::Interaction::Grabbing,
}
}
}
impl Widget<super::super::super::AppMessage, cosmic::Theme, Renderer> for CropOverlay {
fn size(&self) -> Size<Length> {
Size::new(Length::Fill, Length::Fill)
}
fn layout(&self, _tree: &mut Tree, _renderer: &Renderer, limits: &Limits) -> Node {
Node::new(limits.max())
}
fn draw(
&self,
_tree: &Tree,
renderer: &mut Renderer,
_theme: &cosmic::Theme,
_style: &cosmic::iced::advanced::renderer::Style,
layout: Layout<'_>,
_cursor: Cursor,
_viewport: &Rectangle,
) {
let bounds = layout.bounds();
let effective_scale = self.get_effective_scale(&bounds);
if let Some((rx, ry, rw, rh)) = self.selection.region {
if rw > 0.0 && rh > 0.0 {
// Berechne zentrierte Position des Images mit aktuellem Zoom/Pan
let img_screen_width = self.img_width as f32 * effective_scale;
let img_screen_height = self.img_height as f32 * effective_scale;
let offset_x = (bounds.width - img_screen_width) / 2.0 - self.pan_x;
let offset_y = (bounds.height - img_screen_height) / 2.0 - self.pan_y;
let sel_x = bounds.x + offset_x + rx * effective_scale;
let sel_y = bounds.y + offset_y + ry * effective_scale;
let sel_w = rw * effective_scale;
let sel_h = rh * effective_scale;
if sel_y > bounds.y {
renderer.fill_quad(
Quad {
bounds: Rectangle::new(
bounds.position(),
Size::new(bounds.width, sel_y - bounds.y),
),
..Quad::default()
},
OVERLAY_COLOR,
);
}
let sel_bottom = sel_y + sel_h;
let img_bottom = bounds.y + bounds.height;
if sel_bottom < img_bottom {
renderer.fill_quad(
Quad {
bounds: Rectangle::new(
Point::new(bounds.x, sel_bottom),
Size::new(bounds.width, img_bottom - sel_bottom),
),
..Quad::default()
},
OVERLAY_COLOR,
);
}
if sel_x > bounds.x {
renderer.fill_quad(
Quad {
bounds: Rectangle::new(
Point::new(bounds.x, sel_y),
Size::new(sel_x - bounds.x, sel_h),
),
..Quad::default()
},
OVERLAY_COLOR,
);
}
let sel_right = sel_x + sel_w;
let img_right = bounds.x + bounds.width;
if sel_right < img_right {
renderer.fill_quad(
Quad {
bounds: Rectangle::new(
Point::new(sel_right, sel_y),
Size::new(img_right - sel_right, sel_h),
),
..Quad::default()
},
OVERLAY_COLOR,
);
}
let border_width = BORDER_WIDTH;
renderer.fill_quad(
Quad {
bounds: Rectangle::new(
Point::new(sel_x, sel_y),
Size::new(sel_w, border_width),
),
..Quad::default()
},
BORDER_COLOR,
);
renderer.fill_quad(
Quad {
bounds: Rectangle::new(
Point::new(sel_x, sel_y + sel_h - border_width),
Size::new(sel_w, border_width),
),
..Quad::default()
},
BORDER_COLOR,
);
renderer.fill_quad(
Quad {
bounds: Rectangle::new(
Point::new(sel_x, sel_y),
Size::new(border_width, sel_h),
),
..Quad::default()
},
BORDER_COLOR,
);
renderer.fill_quad(
Quad {
bounds: Rectangle::new(
Point::new(sel_x + sel_w - border_width, sel_y),
Size::new(border_width, sel_h),
),
..Quad::default()
},
BORDER_COLOR,
);
let handle_half = HANDLE_SIZE / 2.0;
let handles = [
(sel_x, sel_y),
(sel_x + sel_w, sel_y),
(sel_x, sel_y + sel_h),
(sel_x + sel_w, sel_y + sel_h),
(sel_x + sel_w / 2.0, sel_y),
(sel_x + sel_w / 2.0, sel_y + sel_h),
(sel_x, sel_y + sel_h / 2.0),
(sel_x + sel_w, sel_y + sel_h / 2.0),
];
for (hx, hy) in handles {
renderer.fill_quad(
Quad {
bounds: Rectangle::new(
Point::new(hx - handle_half, hy - handle_half),
Size::new(HANDLE_SIZE, HANDLE_SIZE),
),
..Quad::default()
},
HANDLE_COLOR,
);
}
if self.show_grid && rw > 10.0 && rh > 10.0 {
let grid_sp_x = sel_w / 3.0;
let grid_sp_y = sel_h / 3.0;
for i in 1..3 {
let offset_x = sel_x + grid_sp_x * i as f32;
let offset_y = sel_y + grid_sp_y * i as f32;
renderer.fill_quad(
Quad {
bounds: Rectangle::new(
Point::new(offset_x, sel_y),
Size::new(GRID_WIDTH, sel_h),
),
..Quad::default()
},
GRID_COLOR,
);
renderer.fill_quad(
Quad {
bounds: Rectangle::new(
Point::new(sel_x, offset_y),
Size::new(sel_w, GRID_WIDTH),
),
..Quad::default()
},
GRID_COLOR,
);
}
}
} else {
renderer.fill_quad(
Quad {
bounds,
..Quad::default()
},
OVERLAY_COLOR,
);
}
} else {
renderer.fill_quad(
Quad {
bounds,
..Quad::default()
},
OVERLAY_COLOR,
);
}
}
fn on_event(
&mut self,
_tree: &mut Tree,
event: Event,
layout: Layout<'_>,
cursor: Cursor,
_renderer: &Renderer,
_clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, super::super::super::AppMessage>,
_viewport: &Rectangle,
) -> Status {
let bounds = layout.bounds();
match event {
Event::Mouse(mouse::Event::ButtonPressed(Button::Left)) => {
if let Some(pos) = cursor.position_in(bounds) {
let handle = self.hit_test_handle(&bounds, pos);
let (img_x, img_y) = self.screen_to_image(&bounds, pos);
shell.publish(super::super::super::AppMessage::CropDragStart {
x: img_x,
y: img_y,
handle,
});
// Always capture in crop mode to prevent image viewer from panning
return Status::Captured;
}
}
Event::Mouse(mouse::Event::CursorMoved { .. }) => {
if self.selection.is_dragging {
if let Some(pos) = cursor.position_in(bounds) {
let (img_x, img_y) = self.screen_to_image(&bounds, pos);
shell.publish(super::super::super::AppMessage::CropDragMove {
x: img_x,
y: img_y,
});
return Status::Captured;
}
}
}
Event::Mouse(mouse::Event::ButtonReleased(Button::Left)) => {
if self.selection.is_dragging {
shell.publish(super::super::super::AppMessage::CropDragEnd);
return Status::Captured;
}
}
_ => {}
}
Status::Ignored
}
fn mouse_interaction(
&self,
_tree: &Tree,
layout: Layout<'_>,
cursor: Cursor,
_viewport: &Rectangle,
_renderer: &Renderer,
) -> mouse::Interaction {
let bounds = layout.bounds();
if self.selection.is_dragging {
return self.cursor_for_handle(self.selection.drag_handle);
}
if let Some(pos) = cursor.position_in(bounds) {
let handle = self.hit_test_handle(&bounds, pos);
if handle != DragHandle::None {
return self.cursor_for_handle(handle);
}
if bounds.contains(pos) {
return mouse::Interaction::Crosshair;
}
}
mouse::Interaction::default()
}
}
impl<'a> From<CropOverlay> for Element<'a, super::super::super::AppMessage> {
fn from(overlay: CropOverlay) -> Self {
Self::new(overlay)
}
}
pub fn crop_overlay(
img_width: u32,
img_height: u32,
selection: &CropSelection,
show_grid: bool,
scale: f32,
pan_x: f32,
pan_y: f32,
) -> CropOverlay {
CropOverlay::new(
img_width, img_height, selection, show_grid, scale, pan_x, pan_y,
)
}

View file

@ -0,0 +1,185 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/app/view/crop/selection.rs
//
// Crop selection state and drag handle types.
// Inspired by cosmic-viewer (https://codeberg.org/bhh by Bryan Hyland
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DragHandle {
#[default]
None,
TopLeft,
TopRight,
BottomLeft,
BottomRight,
Top,
Bottom,
Left,
Right,
Move,
}
#[derive(Debug, Clone, Default)]
pub struct CropSelection {
pub region: Option<(f32, f32, f32, f32)>,
pub is_dragging: bool,
pub drag_handle: DragHandle,
pub drag_start: Option<(f32, f32)>,
pub drag_start_region: Option<(f32, f32, f32, f32)>,
}
impl CropSelection {
pub fn start_new_selection(&mut self, x: f32, y: f32) {
self.region = Some((x, y, 0.0, 0.0));
self.is_dragging = true;
self.drag_handle = DragHandle::None;
self.drag_start = Some((x, y));
self.drag_start_region = None;
}
pub fn start_handle_drag(&mut self, handle: DragHandle, x: f32, y: f32) {
self.is_dragging = true;
self.drag_handle = handle;
self.drag_start = Some((x, y));
self.drag_start_region = self.region;
}
pub fn update_drag(&mut self, x: f32, y: f32, img_width: f32, img_height: f32) {
if !self.is_dragging {
return;
}
match self.drag_handle {
DragHandle::None => {
if let Some((start_x, start_y)) = self.drag_start {
let min_x = start_x.min(x).max(0.0);
let min_y = start_y.min(y).max(0.0);
let max_x = start_x.max(x).min(img_width);
let max_y = start_y.max(y).min(img_height);
self.region = Some((min_x, min_y, max_x - min_x, max_y - min_y));
}
}
DragHandle::Move => {
if let (Some((start_x, start_y)), Some((rx, ry, rw, rh))) =
(self.drag_start, self.drag_start_region)
{
let dx = x - start_x;
let dy = y - start_y;
let new_x = (rx + dx).max(0.0).min(img_width - rw);
let new_y = (ry + dy).max(0.0).min(img_height - rh);
self.region = Some((new_x, new_y, rw, rh));
}
}
_ => {
if let (Some((start_x, start_y)), Some((rx, ry, rw, rh))) =
(self.drag_start, self.drag_start_region)
{
let dx = x - start_x;
let dy = y - start_y;
let (new_x, new_y, new_w, new_h) =
self.resize_region(rx, ry, rw, rh, dx, dy, img_width, img_height);
self.region = Some((new_x, new_y, new_w, new_h));
}
}
}
}
fn resize_region(
&self,
rx: f32,
ry: f32,
rw: f32,
rh: f32,
dx: f32,
dy: f32,
img_width: f32,
img_height: f32,
) -> (f32, f32, f32, f32) {
const MIN_SIZE: f32 = 1.0;
let right = rx + rw;
let bottom = ry + rh;
match self.drag_handle {
DragHandle::TopLeft => {
let new_rx = (rx + dx).max(0.0).min(right - MIN_SIZE);
let new_ry = (ry + dy).max(0.0).min(bottom - MIN_SIZE);
let new_rw = (right - new_rx).max(MIN_SIZE).min(img_width - new_rx);
let new_rh = (bottom - new_ry).max(MIN_SIZE).min(img_height - new_ry);
(new_rx, new_ry, new_rw, new_rh)
}
DragHandle::TopRight => {
let new_right = (right + dx).max(rx + MIN_SIZE).min(img_width);
let new_ry = (ry + dy).max(0.0).min(bottom - MIN_SIZE);
let new_rw = (new_right - rx).max(MIN_SIZE);
let new_rh = (bottom - new_ry).max(MIN_SIZE).min(img_height - new_ry);
(rx, new_ry, new_rw, new_rh)
}
DragHandle::BottomLeft => {
let new_rx = (rx + dx).max(0.0).min(right - MIN_SIZE);
let new_bottom = (bottom + dy).max(ry + MIN_SIZE).min(img_height);
let new_rw = (right - new_rx).max(MIN_SIZE);
let new_rh = (new_bottom - ry).max(MIN_SIZE);
(new_rx, ry, new_rw, new_rh)
}
DragHandle::BottomRight => {
let new_right = (right + dx).max(rx + MIN_SIZE).min(img_width);
let new_bottom = (bottom + dy).max(ry + MIN_SIZE).min(img_height);
let new_rw = (new_right - rx).max(MIN_SIZE);
let new_rh = (new_bottom - ry).max(MIN_SIZE);
(rx, ry, new_rw, new_rh)
}
DragHandle::Top => {
let new_ry = (ry + dy).max(0.0).min(bottom - MIN_SIZE);
let new_rh = (bottom - new_ry).max(MIN_SIZE);
(rx, new_ry, rw, new_rh)
}
DragHandle::Bottom => {
let new_bottom = (bottom + dy).max(ry + MIN_SIZE).min(img_height);
let new_rh = (new_bottom - ry).max(MIN_SIZE);
(rx, ry, rw, new_rh)
}
DragHandle::Left => {
let new_rx = (rx + dx).max(0.0).min(right - MIN_SIZE);
let new_rw = (right - new_rx).max(MIN_SIZE);
(new_rx, ry, new_rw, rh)
}
DragHandle::Right => {
let new_right = (right + dx).max(rx + MIN_SIZE).min(img_width);
let new_rw = (new_right - rx).max(MIN_SIZE);
(rx, ry, new_rw, rh)
}
_ => (rx, ry, rw, rh),
}
}
pub fn end_drag(&mut self) {
self.is_dragging = false;
self.drag_start = None;
self.drag_start_region = None;
}
pub fn reset(&mut self) {
self.region = None;
self.is_dragging = false;
self.drag_handle = DragHandle::None;
self.drag_start = None;
self.drag_start_region = None;
}
pub fn has_selection(&self) -> bool {
self.region.is_some_and(|(_, _, w, h)| w > 1.0 && h > 1.0)
}
pub fn as_pixel_rect(&self) -> Option<(u32, u32, u32, u32)> {
self.region.and_then(|(x, y, w, h)| {
if w > 1.0 && h > 1.0 {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
Some((x as u32, y as u32, w as u32, h as u32))
} else {
None
}
})
}
}

View file

@ -4,6 +4,7 @@
// View module root, combining all view components.
mod canvas;
pub mod crop;
pub mod footer;
pub mod header;
mod image_viewer;

View file

@ -24,6 +24,8 @@ pub struct AppConfig {
pub min_scale: f32,
/// Maximum zoom level (8.0 = 800% of original size).
pub max_scale: f32,
/// Show 3x3 grid during crop selection.
pub crop_show_grid: bool,
}
impl Default for AppConfig {
@ -36,6 +38,7 @@ impl Default for AppConfig {
pan_step: 50.0,
min_scale: 0.1,
max_scale: 8.0,
crop_show_grid: true,
}
}
}