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:
parent
9399a008c4
commit
3cf99ad19d
20 changed files with 1042 additions and 103 deletions
5
.zed/settings.json
Normal file
5
.zed/settings.json
Normal 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
|
||||||
|
{}
|
||||||
18
Cargo.toml
18
Cargo.toml
|
|
@ -14,11 +14,23 @@ license = "GPL-3.0-or-later"
|
||||||
keywords = ["document", "image", "viewer", "pdf", "cosmic"]
|
keywords = ["document", "image", "viewer", "pdf", "cosmic"]
|
||||||
categories = ["gui", "multimedia::graphics", "multimedia::images"]
|
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]
|
[dependencies]
|
||||||
# Error handling
|
# Error handling
|
||||||
anyhow = "1"
|
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
|
# Async / concurrency
|
||||||
futures-util = "0.3.31"
|
futures-util = "0.3.31"
|
||||||
|
|
@ -40,10 +52,6 @@ open = "5.3.2"
|
||||||
rust-embed = "8.8.0"
|
rust-embed = "8.8.0"
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
sha2 = "0.10"
|
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"] }
|
clap = { version = "4.5.54", features = ["derive"] }
|
||||||
env_logger = "0.11.8"
|
env_logger = "0.11.8"
|
||||||
wallpaper = "3.2"
|
wallpaper = "3.2"
|
||||||
|
|
|
||||||
|
|
@ -42,14 +42,14 @@ fn cache_key(file_path: &Path, page: usize) -> Option<String> {
|
||||||
hasher.update(page.to_le_bytes());
|
hasher.update(page.to_le_bytes());
|
||||||
|
|
||||||
let hash = hasher.finalize();
|
let hash = hasher.finalize();
|
||||||
Some(format!("{:x}", hash))
|
Some(format!("{hash:x}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the full path for a cached thumbnail.
|
/// Get the full path for a cached thumbnail.
|
||||||
fn thumbnail_path(file_path: &Path, page: usize) -> Option<PathBuf> {
|
fn thumbnail_path(file_path: &Path, page: usize) -> Option<PathBuf> {
|
||||||
let dir = cache_dir()?;
|
let dir = cache_dir()?;
|
||||||
let key = cache_key(file_path, page)?;
|
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.
|
/// 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<()> {
|
pub fn save_thumbnail(file_path: &Path, page: usize, image: &DynamicImage) -> Option<()> {
|
||||||
let dir = ensure_cache_dir()?;
|
let dir = ensure_cache_dir()?;
|
||||||
let key = cache_key(file_path, page)?;
|
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!(
|
log::debug!(
|
||||||
"Saving thumbnail to cache: file={}, page={}, path={}",
|
"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,
|
image::ImageFormat::Png,
|
||||||
);
|
);
|
||||||
match res {
|
match res {
|
||||||
Ok(_) => {
|
Ok(()) => {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"Thumbnail cached successfully: file={} page={}",
|
"Thumbnail cached successfully: file={} page={}",
|
||||||
file_path.display(),
|
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.
|
/// Check if a thumbnail exists in cache.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn has_thumbnail(file_path: &Path, page: usize) -> bool {
|
pub fn has_thumbnail(file_path: &Path, page: usize) -> bool {
|
||||||
thumbnail_path(file_path, page)
|
thumbnail_path(file_path, page).is_some_and(|p| p.exists())
|
||||||
.map(|p| p.exists())
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all cached thumbnails.
|
/// Clear all cached thumbnails.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn clear_cache() -> std::io::Result<()> {
|
pub fn clear_cache() -> std::io::Result<()> {
|
||||||
if let Some(dir) = cache_dir()
|
if let Some(dir) = cache_dir()
|
||||||
&& dir.exists() {
|
&& dir.exists()
|
||||||
fs::remove_dir_all(&dir)?;
|
{
|
||||||
}
|
fs::remove_dir_all(&dir)?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ use crate::app::model::{AppModel, ViewMode};
|
||||||
/// based on enabled codecs (e.g. default-formats).
|
/// based on enabled codecs (e.g. default-formats).
|
||||||
pub fn open_document(path: &Path) -> anyhow::Result<DocumentContent> {
|
pub fn open_document(path: &Path) -> anyhow::Result<DocumentContent> {
|
||||||
let kind = DocumentKind::from_path(path)
|
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 {
|
let content = match kind {
|
||||||
DocumentKind::Raster => {
|
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,
|
/// 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
|
/// open the first one, and initialize navigation state. If it is a file, the
|
||||||
/// file is opened directly and the surrounding folder is scanned.
|
/// 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() {
|
if path.is_dir() {
|
||||||
open_from_directory(model, &path);
|
open_from_directory(model, path);
|
||||||
} else {
|
} 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.
|
// Refresh folder listing based on parent directory.
|
||||||
if model.document.is_some()
|
if model.document.is_some()
|
||||||
&& let Some(parent) = path.parent() {
|
&& let Some(parent) = path.parent()
|
||||||
refresh_folder_entries(model, parent, path);
|
{
|
||||||
}
|
refresh_folder_entries(model, parent, path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load a document into the model, resetting view state.
|
/// 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>> {
|
pub fn read_file_bytes(path: &Path) -> Option<Vec<u8>> {
|
||||||
fs::read(path).ok()
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,14 +38,19 @@ impl BasicMeta {
|
||||||
const MB: u64 = KB * 1024;
|
const MB: u64 = KB * 1024;
|
||||||
const GB: u64 = MB * 1024;
|
const GB: u64 = MB * 1024;
|
||||||
|
|
||||||
|
#[allow(clippy::cast_precision_loss)]
|
||||||
if self.file_size >= GB {
|
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 {
|
} 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 {
|
} 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 {
|
} 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) {
|
if model.starts_with(make) {
|
||||||
Some(model.clone())
|
Some(model.clone())
|
||||||
} else {
|
} else {
|
||||||
Some(format!("{} {}", make, model))
|
Some(format!("{make} {model}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(Some(make), None) => Some(make.clone()),
|
(Some(make), None) => Some(make.clone()),
|
||||||
|
|
@ -89,7 +94,7 @@ impl ExifMeta {
|
||||||
/// Format GPS coordinates for display.
|
/// Format GPS coordinates for display.
|
||||||
pub fn gps_display(&self) -> Option<String> {
|
pub fn gps_display(&self) -> Option<String> {
|
||||||
match (self.gps_latitude, self.gps_longitude) {
|
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,
|
_ => 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)
|
if let Some(field) = exif.get_field(Tag::PhotographicSensitivity, In::PRIMARY)
|
||||||
&& let Value::Short(ref vals) = field.value
|
&& let Value::Short(ref vals) = field.value
|
||||||
&& let Some(&iso) = vals.first() {
|
&& let Some(&iso) = vals.first()
|
||||||
meta.iso = Some(iso as u32);
|
{
|
||||||
}
|
meta.iso = Some(u32::from(iso));
|
||||||
|
}
|
||||||
if let Some(field) = exif.get_field(Tag::FocalLength, In::PRIMARY) {
|
if let Some(field) = exif.get_field(Tag::FocalLength, In::PRIMARY) {
|
||||||
meta.focal_length = Some(field.display_value().to_string());
|
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.
|
/// Determine color type string from DynamicImage.
|
||||||
fn color_type_string(img: &DynamicImage) -> String {
|
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 {
|
match img {
|
||||||
ImageLuma8(_) => "Grayscale 8-bit".to_string(),
|
ImageLuma8(_) => "Grayscale 8-bit".to_string(),
|
||||||
ImageLumaA8(_) => "Grayscale+Alpha 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 {
|
fn format_from_extension(path: &Path) -> String {
|
||||||
path.extension()
|
path.extension()
|
||||||
.and_then(|e| e.to_str())
|
.and_then(|e| e.to_str())
|
||||||
.map(|e| e.to_uppercase())
|
.map_or_else(|| "Unknown".to_string(), str::to_uppercase)
|
||||||
.unwrap_or_else(|| "Unknown".to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -259,7 +267,7 @@ pub fn build_vector_meta(path: &Path, width: u32, height: u32) -> DocumentMeta {
|
||||||
|
|
||||||
/// Build metadata for a portable document.
|
/// Build metadata for a portable document.
|
||||||
pub fn build_portable_meta(path: &Path, width: u32, height: u32, page_count: u32) -> DocumentMeta {
|
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());
|
let basic = extract_basic_meta(path, width, height, &format, "Rendered".to_string());
|
||||||
|
|
||||||
DocumentMeta { basic, exif: None }
|
DocumentMeta { basic, exif: None }
|
||||||
|
|
|
||||||
|
|
@ -6,18 +6,26 @@
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod file;
|
pub mod file;
|
||||||
pub mod meta;
|
pub mod meta;
|
||||||
pub mod portable;
|
|
||||||
pub mod raster;
|
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
||||||
|
#[cfg(feature = "portable")]
|
||||||
|
pub mod portable;
|
||||||
|
#[cfg(feature = "image")]
|
||||||
|
pub mod raster;
|
||||||
|
#[cfg(feature = "vector")]
|
||||||
pub mod vector;
|
pub mod vector;
|
||||||
|
|
||||||
use cosmic::iced_renderer::graphics::image::image_rs::ImageFormat as CosmicImageFormat;
|
use cosmic::iced_renderer::graphics::image::image_rs::ImageFormat as CosmicImageFormat;
|
||||||
|
#[cfg(feature = "image")]
|
||||||
use image::GenericImageView;
|
use image::GenericImageView;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[cfg(feature = "portable")]
|
||||||
use self::portable::PortableDocument;
|
use self::portable::PortableDocument;
|
||||||
|
#[cfg(feature = "image")]
|
||||||
use self::raster::RasterDocument;
|
use self::raster::RasterDocument;
|
||||||
|
#[cfg(feature = "vector")]
|
||||||
use self::vector::VectorDocument;
|
use self::vector::VectorDocument;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -96,8 +104,6 @@ pub struct TransformState {
|
||||||
pub flip_v: bool,
|
pub flip_v: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// Output of a render operation.
|
/// Output of a render operation.
|
||||||
///
|
///
|
||||||
/// Used as return type for the `Renderable::render()` trait method.
|
/// Used as return type for the `Renderable::render()` trait method.
|
||||||
|
|
@ -360,6 +366,18 @@ impl DocumentContent {
|
||||||
self.flip(FlipDirection::Vertical);
|
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.
|
/// Get document kind.
|
||||||
///
|
///
|
||||||
/// Reserved for future use (format-specific optimizations, statistics).
|
/// Reserved for future use (format-specific optimizations, statistics).
|
||||||
|
|
@ -446,7 +464,9 @@ impl DocumentContent {
|
||||||
/// Currently unused - thumbnails are generated incrementally via `generate_thumbnail_page()`.
|
/// Currently unused - thumbnails are generated incrementally via `generate_thumbnail_page()`.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn generate_thumbnails(&mut self) {
|
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.
|
/// Get current image handle for display.
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ impl PortableDocument {
|
||||||
/// Open a PDF document and render the first page.
|
/// Open a PDF document and render the first page.
|
||||||
pub fn open(path: &Path) -> anyhow::Result<Self> {
|
pub fn open(path: &Path) -> anyhow::Result<Self> {
|
||||||
let document = PopplerDocument::new_from_file(path, None)
|
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();
|
let num_pages = document.get_n_pages();
|
||||||
if num_pages == 0 {
|
if num_pages == 0 {
|
||||||
|
|
@ -107,14 +107,13 @@ impl PortableDocument {
|
||||||
return handle;
|
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) => {
|
Ok(img) => {
|
||||||
let _ = cache::save_thumbnail(&self.source_path, page, &img);
|
let _ = cache::save_thumbnail(&self.source_path, page, &img);
|
||||||
super::create_image_handle_from_image(&img)
|
super::create_image_handle_from_image(&img)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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])
|
ImageHandle::from_rgba(1, 1, vec![0, 0, 0, 0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -138,7 +137,7 @@ impl PortableDocument {
|
||||||
) -> anyhow::Result<DynamicImage> {
|
) -> anyhow::Result<DynamicImage> {
|
||||||
let page = document
|
let page = document
|
||||||
.get_page(page_index)
|
.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 (page_width, page_height) = page.get_size();
|
||||||
let rotation_degrees = rotation.to_degrees();
|
let rotation_degrees = rotation.to_degrees();
|
||||||
|
|
@ -155,10 +154,10 @@ impl PortableDocument {
|
||||||
let scaled_height = (height * scale) as i32;
|
let scaled_height = (height * scale) as i32;
|
||||||
|
|
||||||
let surface = ImageSurface::create(Format::ARgb32, scaled_width, scaled_height)
|
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)
|
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.
|
// Fill with white background.
|
||||||
context.set_source_rgb(1.0, 1.0, 1.0);
|
context.set_source_rgb(1.0, 1.0, 1.0);
|
||||||
|
|
@ -182,13 +181,13 @@ impl PortableDocument {
|
||||||
let mut png_data: Vec<u8> = Vec::new();
|
let mut png_data: Vec<u8> = Vec::new();
|
||||||
surface
|
surface
|
||||||
.write_to_png(&mut png_data)
|
.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))
|
let image = ImageReader::new(Cursor::new(png_data))
|
||||||
.with_guessed_format()
|
.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()
|
.decode()
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to decode PNG: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Failed to decode PNG: {e}"))?;
|
||||||
|
|
||||||
Ok(image)
|
Ok(image)
|
||||||
}
|
}
|
||||||
|
|
@ -208,7 +207,7 @@ impl PortableDocument {
|
||||||
self.refresh_handle();
|
self.refresh_handle();
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to render PDF page: {}", e);
|
log::error!("Failed to render PDF page: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,54 @@ impl RasterDocument {
|
||||||
pub fn extract_meta(&self, path: &Path) -> super::meta::DocumentMeta {
|
pub fn extract_meta(&self, path: &Path) -> super::meta::DocumentMeta {
|
||||||
super::meta::build_raster_meta(path, &self.document, self.native_width, self.native_height)
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -22,15 +22,12 @@ pub fn set_as_wallpaper(path: &Path) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let path_str = match abs_path.to_str() {
|
let Some(path_str) = abs_path.to_str() else {
|
||||||
Some(s) => s,
|
log::error!("Invalid UTF-8 in path: {}", abs_path.display());
|
||||||
None => {
|
return;
|
||||||
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).
|
// Method 1: Try COSMIC Desktop (direct config file modification).
|
||||||
if try_cosmic_wallpaper(path_str) {
|
if try_cosmic_wallpaper(path_str) {
|
||||||
|
|
@ -69,23 +66,22 @@ fn try_cosmic_wallpaper(path_str: &str) -> bool {
|
||||||
let config_content = format!(
|
let config_content = format!(
|
||||||
r#"(
|
r#"(
|
||||||
output: "all",
|
output: "all",
|
||||||
source: Path("{}"),
|
source: Path("{path_str}"),
|
||||||
filter_by_theme: true,
|
filter_by_theme: true,
|
||||||
rotation_frequency: 300,
|
rotation_frequency: 300,
|
||||||
filter_method: Lanczos,
|
filter_method: Lanczos,
|
||||||
scaling_mode: Zoom,
|
scaling_mode: Zoom,
|
||||||
sampling_method: Alphanumeric,
|
sampling_method: Alphanumeric,
|
||||||
)"#,
|
)"#
|
||||||
path_str
|
|
||||||
);
|
);
|
||||||
|
|
||||||
match std::fs::write(&cosmic_config, config_content) {
|
match std::fs::write(&cosmic_config, config_content) {
|
||||||
Ok(_) => {
|
Ok(()) => {
|
||||||
log::info!("Wallpaper set via COSMIC config");
|
log::info!("Wallpaper set via COSMIC config");
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("Failed to write COSMIC config: {}", e);
|
log::warn!("Failed to write COSMIC config: {e}");
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -94,12 +90,12 @@ fn try_cosmic_wallpaper(path_str: &str) -> bool {
|
||||||
/// Try setting wallpaper via wallpaper crate.
|
/// Try setting wallpaper via wallpaper crate.
|
||||||
fn try_wallpaper_crate(path_str: &str) -> bool {
|
fn try_wallpaper_crate(path_str: &str) -> bool {
|
||||||
match wallpaper::set_from_path(path_str) {
|
match wallpaper::set_from_path(path_str) {
|
||||||
Ok(_) => {
|
Ok(()) => {
|
||||||
log::info!("Wallpaper set via wallpaper crate");
|
log::info!("Wallpaper set via wallpaper crate");
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("wallpaper crate failed: {}", e);
|
log::warn!("wallpaper crate failed: {e}");
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -107,7 +103,7 @@ fn try_wallpaper_crate(path_str: &str) -> bool {
|
||||||
|
|
||||||
/// Try setting wallpaper via GNOME gsettings.
|
/// Try setting wallpaper via GNOME gsettings.
|
||||||
fn try_gsettings_wallpaper(path_str: &str) -> bool {
|
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")
|
let output = match std::process::Command::new("gsettings")
|
||||||
.args(["set", "org.gnome.desktop.background", "picture-uri", &uri])
|
.args(["set", "org.gnome.desktop.background", "picture-uri", &uri])
|
||||||
|
|
@ -115,7 +111,7 @@ fn try_gsettings_wallpaper(path_str: &str) -> bool {
|
||||||
{
|
{
|
||||||
Ok(o) => o,
|
Ok(o) => o,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("gsettings command failed: {}", e);
|
log::warn!("gsettings command failed: {e}");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -145,15 +141,12 @@ fn try_gsettings_wallpaper(path_str: &str) -> bool {
|
||||||
|
|
||||||
/// Try setting wallpaper via feh.
|
/// Try setting wallpaper via feh.
|
||||||
fn try_feh_wallpaper(path_str: &str) -> bool {
|
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])
|
.args(["--bg-scale", path_str])
|
||||||
.output()
|
.output()
|
||||||
{
|
else {
|
||||||
Ok(o) => o,
|
log::warn!("feh not available");
|
||||||
Err(_) => {
|
return false;
|
||||||
log::warn!("feh not available");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ impl VectorDocument {
|
||||||
|
|
||||||
// Render at native scale (1.0).
|
// Render at native scale (1.0).
|
||||||
let (rendered, width, height) =
|
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);
|
let handle = super::create_image_handle_from_image(&rendered);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
|
@ -90,7 +90,7 @@ impl VectorDocument {
|
||||||
self.native_width,
|
self.native_width,
|
||||||
self.native_height,
|
self.native_height,
|
||||||
scale,
|
scale,
|
||||||
&self.transform,
|
self.transform,
|
||||||
) {
|
) {
|
||||||
Ok((rendered, width, height)) => {
|
Ok((rendered, width, height)) => {
|
||||||
self.current_scale = scale;
|
self.current_scale = scale;
|
||||||
|
|
@ -101,7 +101,7 @@ impl VectorDocument {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -114,7 +114,7 @@ impl VectorDocument {
|
||||||
self.native_width,
|
self.native_width,
|
||||||
self.native_height,
|
self.native_height,
|
||||||
self.current_scale,
|
self.current_scale,
|
||||||
&self.transform,
|
self.transform,
|
||||||
) {
|
) {
|
||||||
self.rendered = rendered;
|
self.rendered = rendered;
|
||||||
self.width = width;
|
self.width = width;
|
||||||
|
|
@ -178,12 +178,12 @@ fn render_document(
|
||||||
native_width: u32,
|
native_width: u32,
|
||||||
native_height: u32,
|
native_height: u32,
|
||||||
scale: f64,
|
scale: f64,
|
||||||
transform: &TransformState,
|
transform: TransformState,
|
||||||
) -> anyhow::Result<(DynamicImage, u32, u32)> {
|
) -> anyhow::Result<(DynamicImage, u32, u32)> {
|
||||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
#[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)]
|
#[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 =
|
let mut pixmap =
|
||||||
Pixmap::new(width, height).ok_or_else(|| anyhow::anyhow!("Failed to create pixmap"))?;
|
Pixmap::new(width, height).ok_or_else(|| anyhow::anyhow!("Failed to create pixmap"))?;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::app::ContextPage;
|
use crate::app::ContextPage;
|
||||||
|
use crate::app::view::crop::DragHandle;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum AppMessage {
|
pub enum AppMessage {
|
||||||
|
|
@ -45,6 +46,21 @@ pub enum AppMessage {
|
||||||
ToggleCropMode,
|
ToggleCropMode,
|
||||||
ToggleScaleMode,
|
ToggleScaleMode,
|
||||||
|
|
||||||
|
// Crop operations.
|
||||||
|
StartCrop,
|
||||||
|
CancelCrop,
|
||||||
|
ApplyCrop,
|
||||||
|
CropDragStart {
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
handle: DragHandle,
|
||||||
|
},
|
||||||
|
CropDragMove {
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
},
|
||||||
|
CropDragEnd,
|
||||||
|
|
||||||
// Panels.
|
// Panels.
|
||||||
ToggleContextPage(ContextPage),
|
ToggleContextPage(ContextPage),
|
||||||
ToggleNavBar,
|
ToggleNavBar,
|
||||||
|
|
@ -53,6 +69,9 @@ pub enum AppMessage {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
RefreshMetadata,
|
RefreshMetadata,
|
||||||
|
|
||||||
|
// Save operations.
|
||||||
|
SaveAs,
|
||||||
|
|
||||||
// Wallpaper.
|
// Wallpaper.
|
||||||
SetAsWallpaper,
|
SetAsWallpaper,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ impl cosmic::Application for Noctua {
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(path) = initial_path {
|
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).
|
// 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.
|
/// Map raw key presses + modifiers into high-level application messages.
|
||||||
fn handle_key_press(key: Key, modifiers: Modifiers) -> Option<AppMessage> {
|
fn handle_key_press(key: Key, modifiers: Modifiers) -> Option<AppMessage> {
|
||||||
|
eprintln!("DEBUG KEY: key={:?} modifiers={:?}", key, modifiers);
|
||||||
use AppMessage::*;
|
use AppMessage::*;
|
||||||
|
|
||||||
// Handle Ctrl + arrow keys for panning.
|
// 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),
|
Key::Character(ch) if ch.eq_ignore_ascii_case("f") => Some(ZoomFit),
|
||||||
|
|
||||||
// Tool modes.
|
// 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),
|
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.
|
// Reset pan.
|
||||||
Key::Character("0") => Some(PanReset),
|
Key::Character("0") => Some(PanReset),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::app::document::meta::DocumentMeta;
|
use crate::app::document::meta::DocumentMeta;
|
||||||
use crate::app::document::DocumentContent;
|
use crate::app::document::DocumentContent;
|
||||||
|
use crate::app::view::crop::CropSelection;
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -58,6 +59,7 @@ pub struct AppModel {
|
||||||
|
|
||||||
// Tools.
|
// Tools.
|
||||||
pub tool_mode: ToolMode,
|
pub tool_mode: ToolMode,
|
||||||
|
pub crop_selection: CropSelection,
|
||||||
|
|
||||||
// UI state.
|
// UI state.
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
|
|
@ -76,6 +78,7 @@ impl AppModel {
|
||||||
pan_x: 0.0,
|
pan_x: 0.0,
|
||||||
pan_y: 0.0,
|
pan_y: 0.0,
|
||||||
tool_mode: ToolMode::None,
|
tool_mode: ToolMode::None,
|
||||||
|
crop_selection: CropSelection::default(),
|
||||||
error: None,
|
error: None,
|
||||||
tick: 0,
|
tick: 0,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,22 +40,24 @@ pub fn update(model: &mut AppModel, msg: &AppMessage, config: &AppConfig) -> Upd
|
||||||
|
|
||||||
AppMessage::GotoPage(page) => {
|
AppMessage::GotoPage(page) => {
|
||||||
if let Some(doc) = &mut model.document
|
if let Some(doc) = &mut model.document
|
||||||
&& let Err(e) = doc.go_to_page(*page) {
|
&& let Err(e) = doc.go_to_page(*page)
|
||||||
log::error!("Failed to navigate to page {}: {}", page, e);
|
{
|
||||||
}
|
log::error!("Failed to navigate to page {page}: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Thumbnail generation -------------------------------------------------
|
// ---- Thumbnail generation -------------------------------------------------
|
||||||
AppMessage::GenerateThumbnailPage(page) => {
|
AppMessage::GenerateThumbnailPage(page) => {
|
||||||
if let Some(doc) = &mut model.document
|
if let Some(doc) = &mut model.document
|
||||||
&& let Some(next_page) = doc.generate_thumbnail_page(*page) {
|
&& let Some(next_page) = doc.generate_thumbnail_page(*page)
|
||||||
return UpdateResult::Task(Task::batch([
|
{
|
||||||
Task::future(async move {
|
return UpdateResult::Task(Task::batch([
|
||||||
Action::App(AppMessage::GenerateThumbnailPage(next_page))
|
Task::future(async move {
|
||||||
}),
|
Action::App(AppMessage::GenerateThumbnailPage(next_page))
|
||||||
Task::done(Action::App(AppMessage::RefreshView)),
|
}),
|
||||||
]));
|
Task::done(Action::App(AppMessage::RefreshView)),
|
||||||
}
|
]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AppMessage::RefreshView => {
|
AppMessage::RefreshView => {
|
||||||
|
|
@ -110,6 +112,10 @@ pub fn update(model: &mut AppModel, msg: &AppMessage, config: &AppConfig) -> Upd
|
||||||
|
|
||||||
// ---- Tool modes ----------------------------------------------------------
|
// ---- Tool modes ----------------------------------------------------------
|
||||||
AppMessage::ToggleCropMode => {
|
AppMessage::ToggleCropMode => {
|
||||||
|
eprintln!(
|
||||||
|
"DEBUG: ToggleCropMode received, current tool_mode={:?}",
|
||||||
|
model.tool_mode
|
||||||
|
);
|
||||||
model.tool_mode = if model.tool_mode == ToolMode::Crop {
|
model.tool_mode = if model.tool_mode == ToolMode::Crop {
|
||||||
ToolMode::None
|
ToolMode::None
|
||||||
} else {
|
} 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 --------------------------------------------
|
// ---- Document transformations --------------------------------------------
|
||||||
AppMessage::FlipHorizontal => {
|
AppMessage::FlipHorizontal => {
|
||||||
if let Some(doc) = &mut model.document {
|
if let Some(doc) = &mut model.document {
|
||||||
|
|
@ -216,3 +284,9 @@ fn set_as_wallpaper(model: &mut AppModel) {
|
||||||
};
|
};
|
||||||
document::set_as_wallpaper(path);
|
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");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@
|
||||||
// Render the center canvas area with the current document.
|
// Render the center canvas area with the current document.
|
||||||
|
|
||||||
use cosmic::iced::{ContentFit, Length};
|
use cosmic::iced::{ContentFit, Length};
|
||||||
|
use cosmic::iced_widget::stack;
|
||||||
use cosmic::widget::{container, text};
|
use cosmic::widget::{container, text};
|
||||||
use cosmic::Element;
|
use cosmic::Element;
|
||||||
|
|
||||||
|
use super::crop::crop_overlay;
|
||||||
use super::image_viewer::Viewer;
|
use super::image_viewer::Viewer;
|
||||||
use crate::app::model::ViewMode;
|
use crate::app::model::{ToolMode, ViewMode};
|
||||||
use crate::app::{AppMessage, AppModel};
|
use crate::app::{AppMessage, AppModel};
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
use crate::fl;
|
use crate::fl;
|
||||||
|
|
@ -17,16 +19,14 @@ use crate::fl;
|
||||||
pub fn view<'a>(model: &'a AppModel, config: &'a AppConfig) -> Element<'a, AppMessage> {
|
pub fn view<'a>(model: &'a AppModel, config: &'a AppConfig) -> Element<'a, AppMessage> {
|
||||||
if let Some(doc) = &model.document {
|
if let Some(doc) = &model.document {
|
||||||
let handle = doc.handle();
|
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 {
|
let (scale, content_fit) = match model.view_mode {
|
||||||
ViewMode::Fit => (1.0, ContentFit::Contain),
|
ViewMode::Fit => (1.0, ContentFit::Contain),
|
||||||
ViewMode::ActualSize => (1.0, ContentFit::None),
|
ViewMode::ActualSize => (1.0, ContentFit::None),
|
||||||
ViewMode::Custom(z) => (z, 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)
|
let img_viewer = Viewer::new(handle)
|
||||||
.with_state(scale, model.pan_x, model.pan_y)
|
.with_state(scale, model.pan_x, model.pan_y)
|
||||||
.on_state_change(|scale, offset_x, offset_y| AppMessage::ViewerStateChanged {
|
.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)
|
.max_scale(config.max_scale)
|
||||||
.scale_step(config.scale_step - 1.0);
|
.scale_step(config.scale_step - 1.0);
|
||||||
|
|
||||||
container(img_viewer)
|
if model.tool_mode == ToolMode::Crop {
|
||||||
.width(Length::Fill)
|
let overlay = crop_overlay(
|
||||||
.height(Length::Fill)
|
width,
|
||||||
.into()
|
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 {
|
} else {
|
||||||
// Placeholder when no document is loaded
|
|
||||||
container(text(fl!("no-document")))
|
container(text(fl!("no-document")))
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.height(Length::Fill)
|
.height(Length::Fill)
|
||||||
|
|
|
||||||
11
src/app/view/crop/mod.rs
Normal file
11
src/app/view/crop/mod.rs
Normal 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;
|
||||||
493
src/app/view/crop/overlay.rs
Normal file
493
src/app/view/crop/overlay.rs
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
185
src/app/view/crop/selection.rs
Normal file
185
src/app/view/crop/selection.rs
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
// View module root, combining all view components.
|
// View module root, combining all view components.
|
||||||
|
|
||||||
mod canvas;
|
mod canvas;
|
||||||
|
pub mod crop;
|
||||||
pub mod footer;
|
pub mod footer;
|
||||||
pub mod header;
|
pub mod header;
|
||||||
mod image_viewer;
|
mod image_viewer;
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ pub struct AppConfig {
|
||||||
pub min_scale: f32,
|
pub min_scale: f32,
|
||||||
/// Maximum zoom level (8.0 = 800% of original size).
|
/// Maximum zoom level (8.0 = 800% of original size).
|
||||||
pub max_scale: f32,
|
pub max_scale: f32,
|
||||||
|
/// Show 3x3 grid during crop selection.
|
||||||
|
pub crop_show_grid: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
|
|
@ -36,6 +38,7 @@ impl Default for AppConfig {
|
||||||
pan_step: 50.0,
|
pan_step: 50.0,
|
||||||
min_scale: 0.1,
|
min_scale: 0.1,
|
||||||
max_scale: 8.0,
|
max_scale: 8.0,
|
||||||
|
crop_show_grid: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue