diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..79860b3 --- /dev/null +++ b/.zed/settings.json @@ -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 +{} diff --git a/Cargo.toml b/Cargo.toml index 302b930..aaaf56c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/app/document/cache.rs b/src/app/document/cache.rs index 7389a9c..ab4ac37 100644 --- a/src/app/document/cache.rs +++ b/src/app/document/cache.rs @@ -42,14 +42,14 @@ fn cache_key(file_path: &Path, page: usize) -> Option { 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 { 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 { 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(()) } diff --git a/src/app/document/file.rs b/src/app/document/file.rs index 048846e..14d04b8 100644 --- a/src/app/document/file.rs +++ b/src/app/document/file.rs @@ -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 { 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 { /// 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> { 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 { + 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) +} diff --git a/src/app/document/meta.rs b/src/app/document/meta.rs index 56bf9d2..5347977 100644 --- a/src/app/document/meta.rs +++ b/src/app/document/meta.rs @@ -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 { 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 { } 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 } diff --git a/src/app/document/mod.rs b/src/app/document/mod.rs index b1f91ce..3724cb5 100644 --- a/src/app/document/mod.rs +++ b/src/app/document/mod.rs @@ -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. diff --git a/src/app/document/portable.rs b/src/app/document/portable.rs index fc4ef2b..acc5b75 100644 --- a/src/app/document/portable.rs +++ b/src/app/document/portable.rs @@ -40,7 +40,7 @@ impl PortableDocument { /// Open a PDF document and render the first page. pub fn open(path: &Path) -> anyhow::Result { 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 { 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 = 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}"); } } } diff --git a/src/app/document/raster.rs b/src/app/document/raster.rs index efbd278..03a3218 100644 --- a/src/app/document/raster.rs +++ b/src/app/document/raster.rs @@ -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 { + 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)) + } } // ============================================================================ diff --git a/src/app/document/utils.rs b/src/app/document/utils.rs index 3118265..271a274 100644 --- a/src/app/document/utils.rs +++ b/src/app/document/utils.rs @@ -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() { diff --git a/src/app/document/vector.rs b/src/app/document/vector.rs index 1bbe9dd..8a16256 100644 --- a/src/app/document/vector.rs +++ b/src/app/document/vector.rs @@ -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"))?; diff --git a/src/app/message.rs b/src/app/message.rs index 77c48e8..879bd59 100644 --- a/src/app/message.rs +++ b/src/app/message.rs @@ -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, diff --git a/src/app/mod.rs b/src/app/mod.rs index 2755147..1ea2803 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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 { + 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 { 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), diff --git a/src/app/model.rs b/src/app/model.rs index 271694c..ec7a184 100644 --- a/src/app/model.rs +++ b/src/app/model.rs @@ -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, @@ -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, } diff --git a/src/app/update.rs b/src/app/update.rs index a11815c..a48f627 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -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"); +} diff --git a/src/app/view/canvas.rs b/src/app/view/canvas.rs index 61f8c30..a695c23 100644 --- a/src/app/view/canvas.rs +++ b/src/app/view/canvas.rs @@ -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) diff --git a/src/app/view/crop/mod.rs b/src/app/view/crop/mod.rs new file mode 100644 index 0000000..8c289df --- /dev/null +++ b/src/app/view/crop/mod.rs @@ -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; diff --git a/src/app/view/crop/overlay.rs b/src/app/view/crop/overlay.rs new file mode 100644 index 0000000..033453d --- /dev/null +++ b/src/app/view/crop/overlay.rs @@ -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 for CropOverlay { + fn size(&self) -> Size { + 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 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, + ) +} diff --git a/src/app/view/crop/selection.rs b/src/app/view/crop/selection.rs new file mode 100644 index 0000000..0820fda --- /dev/null +++ b/src/app/view/crop/selection.rs @@ -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 + } + }) + } +} diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index eaf330b..6a8b185 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -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; diff --git a/src/config.rs b/src/config.rs index 816ff23..adcf9eb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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, } } }