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"]
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -42,14 +42,14 @@ fn cache_key(file_path: &Path, page: usize) -> Option<String> {
|
|||
hasher.update(page.to_le_bytes());
|
||||
|
||||
let hash = hasher.finalize();
|
||||
Some(format!("{:x}", hash))
|
||||
Some(format!("{hash:x}"))
|
||||
}
|
||||
|
||||
/// Get the full path for a cached thumbnail.
|
||||
fn thumbnail_path(file_path: &Path, page: usize) -> Option<PathBuf> {
|
||||
let dir = cache_dir()?;
|
||||
let key = cache_key(file_path, page)?;
|
||||
Some(dir.join(format!("{}.{}", key, THUMBNAIL_EXT)))
|
||||
Some(dir.join(format!("{key}.{THUMBNAIL_EXT}")))
|
||||
}
|
||||
|
||||
/// Load a thumbnail from disk cache.
|
||||
|
|
@ -81,7 +81,7 @@ pub fn load_thumbnail(file_path: &Path, page: usize) -> Option<ImageHandle> {
|
|||
pub fn save_thumbnail(file_path: &Path, page: usize, image: &DynamicImage) -> Option<()> {
|
||||
let dir = ensure_cache_dir()?;
|
||||
let key = cache_key(file_path, page)?;
|
||||
let cache_path = dir.join(format!("{}.{}", key, THUMBNAIL_EXT));
|
||||
let cache_path = dir.join(format!("{key}.{THUMBNAIL_EXT}"));
|
||||
|
||||
log::debug!(
|
||||
"Saving thumbnail to cache: file={}, page={}, path={}",
|
||||
|
|
@ -98,7 +98,7 @@ pub fn save_thumbnail(file_path: &Path, page: usize, image: &DynamicImage) -> Op
|
|||
image::ImageFormat::Png,
|
||||
);
|
||||
match res {
|
||||
Ok(_) => {
|
||||
Ok(()) => {
|
||||
log::debug!(
|
||||
"Thumbnail cached successfully: file={} page={}",
|
||||
file_path.display(),
|
||||
|
|
@ -121,17 +121,16 @@ pub fn save_thumbnail(file_path: &Path, page: usize, image: &DynamicImage) -> Op
|
|||
/// Check if a thumbnail exists in cache.
|
||||
#[allow(dead_code)]
|
||||
pub fn has_thumbnail(file_path: &Path, page: usize) -> bool {
|
||||
thumbnail_path(file_path, page)
|
||||
.map(|p| p.exists())
|
||||
.unwrap_or(false)
|
||||
thumbnail_path(file_path, page).is_some_and(|p| p.exists())
|
||||
}
|
||||
|
||||
/// Clear all cached thumbnails.
|
||||
#[allow(dead_code)]
|
||||
pub fn clear_cache() -> std::io::Result<()> {
|
||||
if let Some(dir) = cache_dir()
|
||||
&& dir.exists() {
|
||||
fs::remove_dir_all(&dir)?;
|
||||
}
|
||||
&& dir.exists()
|
||||
{
|
||||
fs::remove_dir_all(&dir)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ use crate::app::model::{AppModel, ViewMode};
|
|||
/// based on enabled codecs (e.g. default-formats).
|
||||
pub fn open_document(path: &Path) -> anyhow::Result<DocumentContent> {
|
||||
let kind = DocumentKind::from_path(path)
|
||||
.ok_or_else(|| anyhow!("Unsupported document type: {:?}", path))?;
|
||||
.ok_or_else(|| anyhow!("Unsupported document type: {}", path.display()))?;
|
||||
|
||||
let content = match kind {
|
||||
DocumentKind::Raster => {
|
||||
|
|
@ -46,11 +46,11 @@ pub fn open_document(path: &Path) -> anyhow::Result<DocumentContent> {
|
|||
/// If `path` is a directory, this will collect supported documents inside it,
|
||||
/// open the first one, and initialize navigation state. If it is a file, the
|
||||
/// file is opened directly and the surrounding folder is scanned.
|
||||
pub fn open_initial_path(model: &mut AppModel, path: PathBuf) {
|
||||
pub fn open_initial_path(model: &mut AppModel, path: &PathBuf) {
|
||||
if path.is_dir() {
|
||||
open_from_directory(model, &path);
|
||||
open_from_directory(model, path);
|
||||
} else {
|
||||
open_single_file(model, &path);
|
||||
open_single_file(model, path);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -80,9 +80,10 @@ pub fn open_single_file(model: &mut AppModel, path: &Path) {
|
|||
|
||||
// Refresh folder listing based on parent directory.
|
||||
if model.document.is_some()
|
||||
&& let Some(parent) = path.parent() {
|
||||
refresh_folder_entries(model, parent, path);
|
||||
}
|
||||
&& let Some(parent) = path.parent()
|
||||
{
|
||||
refresh_folder_entries(model, parent, path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a document into the model, resetting view state.
|
||||
|
|
@ -200,3 +201,51 @@ pub fn file_size(path: &Path) -> u64 {
|
|||
pub fn read_file_bytes(path: &Path) -> Option<Vec<u8>> {
|
||||
fs::read(path).ok()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Crop operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Save a cropped version of the document with coordinates in filename.
|
||||
///
|
||||
/// Format: "original_NAME_X_Y.EXT"
|
||||
/// Example: "image.png" → "image_100_200.png"
|
||||
pub fn save_crop_as(
|
||||
doc: &DocumentContent,
|
||||
original_path: &Path,
|
||||
x: u32,
|
||||
y: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<PathBuf, String> {
|
||||
let stem = original_path
|
||||
.file_stem()
|
||||
.ok_or_else(|| "Invalid path".to_string())?
|
||||
.to_string_lossy();
|
||||
let ext = original_path
|
||||
.extension()
|
||||
.ok_or_else(|| "No extension".to_string())?
|
||||
.to_string_lossy();
|
||||
|
||||
let new_filename = format!("{stem}_{x}_{y}");
|
||||
let new_path = original_path
|
||||
.with_file_name(&new_filename)
|
||||
.with_extension(ext.as_ref());
|
||||
|
||||
match doc {
|
||||
DocumentContent::Raster(raster_doc) => {
|
||||
let cropped_image = raster_doc
|
||||
.crop_to_image(x, y, width, height)
|
||||
.map_err(|e| e.to_string())?;
|
||||
cropped_image.save(&new_path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
DocumentContent::Vector(_) => {
|
||||
return Err("Crop not supported for vector documents".to_string());
|
||||
}
|
||||
DocumentContent::Portable(_) => {
|
||||
return Err("Crop not supported for PDF documents".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(new_path)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,14 +38,19 @@ impl BasicMeta {
|
|||
const MB: u64 = KB * 1024;
|
||||
const GB: u64 = MB * 1024;
|
||||
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
if self.file_size >= GB {
|
||||
format!("{:.2} GB", self.file_size as f64 / GB as f64)
|
||||
let size_gb = self.file_size as f64 / GB as f64;
|
||||
format!("{size_gb:.2} GB")
|
||||
} else if self.file_size >= MB {
|
||||
format!("{:.2} MB", self.file_size as f64 / MB as f64)
|
||||
let size_mb = self.file_size as f64 / MB as f64;
|
||||
format!("{size_mb:.2} MB")
|
||||
} else if self.file_size >= KB {
|
||||
format!("{:.1} KB", self.file_size as f64 / KB as f64)
|
||||
let size_kb = self.file_size as f64 / KB as f64;
|
||||
format!("{size_kb:.1} KB")
|
||||
} else {
|
||||
format!("{} B", self.file_size)
|
||||
let size = self.file_size;
|
||||
format!("{size} B")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -77,7 +82,7 @@ impl ExifMeta {
|
|||
if model.starts_with(make) {
|
||||
Some(model.clone())
|
||||
} else {
|
||||
Some(format!("{} {}", make, model))
|
||||
Some(format!("{make} {model}"))
|
||||
}
|
||||
}
|
||||
(Some(make), None) => Some(make.clone()),
|
||||
|
|
@ -89,7 +94,7 @@ impl ExifMeta {
|
|||
/// Format GPS coordinates for display.
|
||||
pub fn gps_display(&self) -> Option<String> {
|
||||
match (self.gps_latitude, self.gps_longitude) {
|
||||
(Some(lat), Some(lon)) => Some(format!("{:.5}, {:.5}", lat, lon)),
|
||||
(Some(lat), Some(lon)) => Some(format!("{lat:.5}, {lon:.5}")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -165,9 +170,10 @@ fn extract_exif_from_bytes(data: &[u8]) -> Option<ExifMeta> {
|
|||
}
|
||||
if let Some(field) = exif.get_field(Tag::PhotographicSensitivity, In::PRIMARY)
|
||||
&& let Value::Short(ref vals) = field.value
|
||||
&& let Some(&iso) = vals.first() {
|
||||
meta.iso = Some(iso as u32);
|
||||
}
|
||||
&& let Some(&iso) = vals.first()
|
||||
{
|
||||
meta.iso = Some(u32::from(iso));
|
||||
}
|
||||
if let Some(field) = exif.get_field(Tag::FocalLength, In::PRIMARY) {
|
||||
meta.focal_length = Some(field.display_value().to_string());
|
||||
}
|
||||
|
|
@ -210,7 +216,10 @@ fn extract_gps_coord(exif: &exif::Exif, coord_tag: Tag, ref_tag: Tag) -> Option<
|
|||
|
||||
/// Determine color type string from DynamicImage.
|
||||
fn color_type_string(img: &DynamicImage) -> String {
|
||||
use image::DynamicImage::*;
|
||||
use image::DynamicImage::{
|
||||
ImageLuma8, ImageLumaA8, ImageRgb8, ImageRgba8, ImageLuma16, ImageLumaA16, ImageRgb16,
|
||||
ImageRgba16, ImageRgb32F, ImageRgba32F,
|
||||
};
|
||||
match img {
|
||||
ImageLuma8(_) => "Grayscale 8-bit".to_string(),
|
||||
ImageLumaA8(_) => "Grayscale+Alpha 8-bit".to_string(),
|
||||
|
|
@ -230,8 +239,7 @@ fn color_type_string(img: &DynamicImage) -> String {
|
|||
fn format_from_extension(path: &Path) -> String {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_uppercase())
|
||||
.unwrap_or_else(|| "Unknown".to_string())
|
||||
.map_or_else(|| "Unknown".to_string(), str::to_uppercase)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -259,7 +267,7 @@ pub fn build_vector_meta(path: &Path, width: u32, height: u32) -> DocumentMeta {
|
|||
|
||||
/// Build metadata for a portable document.
|
||||
pub fn build_portable_meta(path: &Path, width: u32, height: u32, page_count: u32) -> DocumentMeta {
|
||||
let format = format!("PDF ({} pages)", page_count);
|
||||
let format = format!("PDF ({page_count} pages)");
|
||||
let basic = extract_basic_meta(path, width, height, &format, "Rendered".to_string());
|
||||
|
||||
DocumentMeta { basic, exif: None }
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ impl PortableDocument {
|
|||
/// Open a PDF document and render the first page.
|
||||
pub fn open(path: &Path) -> anyhow::Result<Self> {
|
||||
let document = PopplerDocument::new_from_file(path, None)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse PDF: {}", e))?;
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse PDF: {e}"))?;
|
||||
|
||||
let num_pages = document.get_n_pages();
|
||||
if num_pages == 0 {
|
||||
|
|
@ -107,14 +107,13 @@ impl PortableDocument {
|
|||
return handle;
|
||||
}
|
||||
|
||||
match Self::render_page_at_scale(&self.document, page, Rotation::None, PDF_THUMBNAIL_SIZE)
|
||||
{
|
||||
match Self::render_page_at_scale(&self.document, page, Rotation::None, PDF_THUMBNAIL_SIZE) {
|
||||
Ok(img) => {
|
||||
let _ = cache::save_thumbnail(&self.source_path, page, &img);
|
||||
super::create_image_handle_from_image(&img)
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to generate thumbnail for page {}: {}", page, e);
|
||||
log::warn!("Failed to generate thumbnail for page {page}: {e}");
|
||||
ImageHandle::from_rgba(1, 1, vec![0, 0, 0, 0])
|
||||
}
|
||||
}
|
||||
|
|
@ -138,7 +137,7 @@ impl PortableDocument {
|
|||
) -> anyhow::Result<DynamicImage> {
|
||||
let page = document
|
||||
.get_page(page_index)
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to get page {}", page_index))?;
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to get page {page_index}"))?;
|
||||
|
||||
let (page_width, page_height) = page.get_size();
|
||||
let rotation_degrees = rotation.to_degrees();
|
||||
|
|
@ -155,10 +154,10 @@ impl PortableDocument {
|
|||
let scaled_height = (height * scale) as i32;
|
||||
|
||||
let surface = ImageSurface::create(Format::ARgb32, scaled_width, scaled_height)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create Cairo surface: {}", e))?;
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create Cairo surface: {e}"))?;
|
||||
|
||||
let context = Context::new(&surface)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create Cairo context: {}", e))?;
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create Cairo context: {e}"))?;
|
||||
|
||||
// Fill with white background.
|
||||
context.set_source_rgb(1.0, 1.0, 1.0);
|
||||
|
|
@ -182,13 +181,13 @@ impl PortableDocument {
|
|||
let mut png_data: Vec<u8> = Vec::new();
|
||||
surface
|
||||
.write_to_png(&mut png_data)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to write PNG: {}", e))?;
|
||||
.map_err(|e| anyhow::anyhow!("Failed to write PNG: {e}"))?;
|
||||
|
||||
let image = ImageReader::new(Cursor::new(png_data))
|
||||
.with_guessed_format()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read PNG format: {}", e))?
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read PNG format: {e}"))?
|
||||
.decode()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to decode PNG: {}", e))?;
|
||||
.map_err(|e| anyhow::anyhow!("Failed to decode PNG: {e}"))?;
|
||||
|
||||
Ok(image)
|
||||
}
|
||||
|
|
@ -208,7 +207,7 @@ impl PortableDocument {
|
|||
self.refresh_handle();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to render PDF page: {}", e);
|
||||
log::error!("Failed to render PDF page: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,54 @@ impl RasterDocument {
|
|||
pub fn extract_meta(&self, path: &Path) -> super::meta::DocumentMeta {
|
||||
super::meta::build_raster_meta(path, &self.document, self.native_width, self.native_height)
|
||||
}
|
||||
|
||||
/// Crop the image to the specified rectangle.
|
||||
///
|
||||
/// Coordinates are in pixels relative to the current image dimensions.
|
||||
/// Returns an error if the rectangle is out of bounds.
|
||||
pub fn crop(&mut self, x: u32, y: u32, width: u32, height: u32) -> DocResult<()> {
|
||||
let (img_width, img_height) = self.document.dimensions();
|
||||
|
||||
if x + width > img_width || y + height > img_height {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Crop rectangle out of bounds: {width}x{height} at ({x}, {y}) exceeds image size {img_width}x{img_height}"
|
||||
));
|
||||
}
|
||||
|
||||
let cropped = imageops::crop_imm(&self.document, x, y, width, height).to_image();
|
||||
self.document = DynamicImage::ImageRgba8(cropped);
|
||||
|
||||
self.native_width = width;
|
||||
self.native_height = height;
|
||||
|
||||
self.transform = TransformState::default();
|
||||
|
||||
self.refresh_handle();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Crop the image to the specified rectangle and return as DynamicImage.
|
||||
///
|
||||
/// This does NOT modify the document - it's used for exporting cropped images.
|
||||
pub fn crop_to_image(
|
||||
&self,
|
||||
x: u32,
|
||||
y: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> DocResult<DynamicImage> {
|
||||
let (img_width, img_height) = self.document.dimensions();
|
||||
|
||||
if x + width > img_width || y + height > img_height {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Crop rectangle out of bounds: {width}x{height} at ({x}, {y}) exceeds image size {img_width}x{img_height}"
|
||||
));
|
||||
}
|
||||
|
||||
let cropped = imageops::crop_imm(&self.document, x, y, width, height).to_image();
|
||||
Ok(DynamicImage::ImageRgba8(cropped))
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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"))?;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ impl cosmic::Application for Noctua {
|
|||
});
|
||||
|
||||
if let Some(path) = initial_path {
|
||||
document::file::open_initial_path(&mut model, path);
|
||||
document::file::open_initial_path(&mut model, &path);
|
||||
}
|
||||
|
||||
// Initialize nav bar model (required for COSMIC to show toggle icon).
|
||||
|
|
@ -221,6 +221,7 @@ impl Noctua {
|
|||
|
||||
/// Map raw key presses + modifiers into high-level application messages.
|
||||
fn handle_key_press(key: Key, modifiers: Modifiers) -> Option<AppMessage> {
|
||||
eprintln!("DEBUG KEY: key={:?} modifiers={:?}", key, modifiers);
|
||||
use AppMessage::*;
|
||||
|
||||
// Handle Ctrl + arrow keys for panning.
|
||||
|
|
@ -262,9 +263,16 @@ fn handle_key_press(key: Key, modifiers: Modifiers) -> Option<AppMessage> {
|
|||
Key::Character(ch) if ch.eq_ignore_ascii_case("f") => Some(ZoomFit),
|
||||
|
||||
// Tool modes.
|
||||
Key::Character(ch) if ch.eq_ignore_ascii_case("c") => Some(ToggleCropMode),
|
||||
Key::Character(ch) if ch.eq_ignore_ascii_case("c") => {
|
||||
eprintln!("DEBUG MATCH: ToggleCropMode");
|
||||
Some(ToggleCropMode)
|
||||
}
|
||||
Key::Character(ch) if ch.eq_ignore_ascii_case("s") => Some(ToggleScaleMode),
|
||||
|
||||
// Crop mode actions (Enter/Escape handled via key press, validated in update).
|
||||
Key::Named(Named::Enter) => Some(AppMessage::ApplyCrop),
|
||||
Key::Named(Named::Escape) => Some(AppMessage::CancelCrop),
|
||||
|
||||
// Reset pan.
|
||||
Key::Character("0") => Some(PanReset),
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use std::path::PathBuf;
|
|||
|
||||
use crate::app::document::meta::DocumentMeta;
|
||||
use crate::app::document::DocumentContent;
|
||||
use crate::app::view::crop::CropSelection;
|
||||
use crate::config::AppConfig;
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -58,6 +59,7 @@ pub struct AppModel {
|
|||
|
||||
// Tools.
|
||||
pub tool_mode: ToolMode,
|
||||
pub crop_selection: CropSelection,
|
||||
|
||||
// UI state.
|
||||
pub error: Option<String>,
|
||||
|
|
@ -76,6 +78,7 @@ impl AppModel {
|
|||
pan_x: 0.0,
|
||||
pan_y: 0.0,
|
||||
tool_mode: ToolMode::None,
|
||||
crop_selection: CropSelection::default(),
|
||||
error: None,
|
||||
tick: 0,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@
|
|||
// Render the center canvas area with the current document.
|
||||
|
||||
use cosmic::iced::{ContentFit, Length};
|
||||
use cosmic::iced_widget::stack;
|
||||
use cosmic::widget::{container, text};
|
||||
use cosmic::Element;
|
||||
|
||||
use super::crop::crop_overlay;
|
||||
use super::image_viewer::Viewer;
|
||||
use crate::app::model::ViewMode;
|
||||
use crate::app::model::{ToolMode, ViewMode};
|
||||
use crate::app::{AppMessage, AppModel};
|
||||
use crate::config::AppConfig;
|
||||
use crate::fl;
|
||||
|
|
@ -17,16 +19,14 @@ use crate::fl;
|
|||
pub fn view<'a>(model: &'a AppModel, config: &'a AppConfig) -> Element<'a, AppMessage> {
|
||||
if let Some(doc) = &model.document {
|
||||
let handle = doc.handle();
|
||||
let (width, height) = doc.dimensions();
|
||||
|
||||
// Determine zoom scale and content fit based on view mode
|
||||
let (scale, content_fit) = match model.view_mode {
|
||||
ViewMode::Fit => (1.0, ContentFit::Contain),
|
||||
ViewMode::ActualSize => (1.0, ContentFit::None),
|
||||
ViewMode::Custom(z) => (z, ContentFit::None),
|
||||
};
|
||||
|
||||
// Use our forked viewer with external state control
|
||||
// scale_step is (scale_step - 1.0) because viewer uses additive step
|
||||
let img_viewer = Viewer::new(handle)
|
||||
.with_state(scale, model.pan_x, model.pan_y)
|
||||
.on_state_change(|scale, offset_x, offset_y| AppMessage::ViewerStateChanged {
|
||||
|
|
@ -41,12 +41,25 @@ pub fn view<'a>(model: &'a AppModel, config: &'a AppConfig) -> Element<'a, AppMe
|
|||
.max_scale(config.max_scale)
|
||||
.scale_step(config.scale_step - 1.0);
|
||||
|
||||
container(img_viewer)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
if model.tool_mode == ToolMode::Crop {
|
||||
let overlay = crop_overlay(
|
||||
width,
|
||||
height,
|
||||
&model.crop_selection,
|
||||
config.crop_show_grid,
|
||||
scale,
|
||||
model.pan_x,
|
||||
model.pan_y,
|
||||
);
|
||||
|
||||
stack![overlay, img_viewer].into()
|
||||
} else {
|
||||
container(img_viewer)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
} else {
|
||||
// Placeholder when no document is loaded
|
||||
container(text(fl!("no-document")))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
|
|
|
|||
11
src/app/view/crop/mod.rs
Normal file
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.
|
||||
|
||||
mod canvas;
|
||||
pub mod crop;
|
||||
pub mod footer;
|
||||
pub mod header;
|
||||
mod image_viewer;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue