refactor: improve code quality and consistency
Comprehensive code audit addressing naming consistency, dead code removal, and documentation improvements across the entire codebase. - Rename panel_pages.rs → pages_panel.rs for consistent naming - Unify view functions: all panels now use view() convention - Rename header_start/end() → start/end() for brevity - Update all imports and references - Remove Rotation::from_degrees() (only 4 fixed states needed) - Remove TransformState::identity() and is_identity() (Default suffices) - Remove duplicate convenience methods from RasterDocument, VectorDocument, PortableDocument (rotate_cw/ccw, flip_horizontal/vertical) - Remove unused imports and constants (ROTATION_STEP, FULL_ROTATION) - Rename constants: PDF_RENDER_SCALE → PDF_RENDER_QUALITY, PDF_THUMBNAIL_SCALE → PDF_THUMBNAIL_SIZE - Add comprehensive trait documentation explaining type erasure pattern - Document why large enum variants are acceptable - Add #[allow(dead_code)] with explanations for trait API types - Improve all constant and config comments - Collapse nested if statements using Rust 2024 let-chains - Replace single-arm match with if-let - Introduce StateChangeCallback<Message> type alias - Apply clippy auto-fixes for better code style
This commit is contained in:
parent
0e31b146a3
commit
9399a008c4
19 changed files with 744 additions and 418 deletions
|
|
@ -27,7 +27,7 @@ fn ensure_cache_dir() -> Option<PathBuf> {
|
|||
|
||||
/// Generate a cache key from file path, modification time, and page number.
|
||||
/// Format: sha256(path + mtime + page)
|
||||
fn cache_key(file_path: &Path, page: u32) -> Option<String> {
|
||||
fn cache_key(file_path: &Path, page: usize) -> Option<String> {
|
||||
let metadata = fs::metadata(file_path).ok()?;
|
||||
let mtime = metadata
|
||||
.modified()
|
||||
|
|
@ -46,7 +46,7 @@ fn cache_key(file_path: &Path, page: u32) -> Option<String> {
|
|||
}
|
||||
|
||||
/// Get the full path for a cached thumbnail.
|
||||
fn thumbnail_path(file_path: &Path, page: u32) -> Option<PathBuf> {
|
||||
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)))
|
||||
|
|
@ -54,7 +54,7 @@ fn thumbnail_path(file_path: &Path, page: u32) -> Option<PathBuf> {
|
|||
|
||||
/// Load a thumbnail from disk cache.
|
||||
/// Returns None if not cached or cache is invalid.
|
||||
pub fn load_thumbnail(file_path: &Path, page: u32) -> Option<ImageHandle> {
|
||||
pub fn load_thumbnail(file_path: &Path, page: usize) -> Option<ImageHandle> {
|
||||
let cache_path = thumbnail_path(file_path, page)?;
|
||||
|
||||
log::debug!("Cache lookup: file={}, page={}", file_path.display(), page);
|
||||
|
|
@ -74,11 +74,11 @@ pub fn load_thumbnail(file_path: &Path, page: u32) -> Option<ImageHandle> {
|
|||
file_path.display(),
|
||||
page
|
||||
);
|
||||
Some(super::create_image_handle(&img))
|
||||
Some(super::create_image_handle_from_image(&img))
|
||||
}
|
||||
|
||||
/// Save a thumbnail to disk cache.
|
||||
pub fn save_thumbnail(file_path: &Path, page: u32, image: &DynamicImage) -> Option<()> {
|
||||
pub fn save_thumbnail(file_path: &Path, page: usize, image: &DynamicImage) -> Option<()> {
|
||||
let dir = ensure_cache_dir()?;
|
||||
let key = cache_key(file_path, page)?;
|
||||
let cache_path = dir.join(format!("{}.{}", key, THUMBNAIL_EXT));
|
||||
|
|
@ -119,7 +119,8 @@ pub fn save_thumbnail(file_path: &Path, page: u32, image: &DynamicImage) -> Opti
|
|||
}
|
||||
|
||||
/// Check if a thumbnail exists in cache.
|
||||
pub fn has_thumbnail(file_path: &Path, page: u32) -> bool {
|
||||
#[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)
|
||||
|
|
@ -128,10 +129,9 @@ pub fn has_thumbnail(file_path: &Path, page: u32) -> bool {
|
|||
/// Clear all cached thumbnails.
|
||||
#[allow(dead_code)]
|
||||
pub fn clear_cache() -> std::io::Result<()> {
|
||||
if let Some(dir) = cache_dir() {
|
||||
if dir.exists() {
|
||||
if let Some(dir) = cache_dir()
|
||||
&& dir.exists() {
|
||||
fs::remove_dir_all(&dir)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,11 +79,10 @@ pub fn open_single_file(model: &mut AppModel, path: &Path) {
|
|||
load_document_into_model(model, path);
|
||||
|
||||
// Refresh folder listing based on parent directory.
|
||||
if model.document.is_some() {
|
||||
if let Some(parent) = path.parent() {
|
||||
if model.document.is_some()
|
||||
&& let Some(parent) = path.parent() {
|
||||
refresh_folder_entries(model, parent, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a document into the model, resetting view state.
|
||||
|
|
|
|||
|
|
@ -163,13 +163,11 @@ fn extract_exif_from_bytes(data: &[u8]) -> Option<ExifMeta> {
|
|||
if let Some(field) = exif.get_field(Tag::FNumber, In::PRIMARY) {
|
||||
meta.f_number = Some(format!("f/{}", field.display_value()));
|
||||
}
|
||||
if let Some(field) = exif.get_field(Tag::PhotographicSensitivity, In::PRIMARY) {
|
||||
if let Value::Short(ref vals) = field.value {
|
||||
if let Some(&iso) = vals.first() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(field) = exif.get_field(Tag::FocalLength, In::PRIMARY) {
|
||||
meta.focal_length = Some(field.display_value().to_string());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,42 +20,182 @@ use self::portable::PortableDocument;
|
|||
use self::raster::RasterDocument;
|
||||
use self::vector::VectorDocument;
|
||||
|
||||
/// Trait for documents that support multiple pages (PDF, multi-page TIFF, etc.).
|
||||
pub trait MultiPage {
|
||||
/// Total number of pages in the document.
|
||||
fn page_count(&self) -> u32;
|
||||
// ============================================================================
|
||||
// Type Definitions
|
||||
// ============================================================================
|
||||
|
||||
/// Current page index (0-based).
|
||||
fn current_page(&self) -> u32;
|
||||
/// Result type alias for document operations.
|
||||
pub type DocResult<T> = anyhow::Result<T>;
|
||||
|
||||
/// Rotation state for documents.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Rotation {
|
||||
/// No rotation (0 degrees).
|
||||
#[default]
|
||||
None,
|
||||
/// 90 degrees clockwise.
|
||||
Cw90,
|
||||
/// 180 degrees.
|
||||
Cw180,
|
||||
/// 270 degrees clockwise (90 counter-clockwise).
|
||||
Cw270,
|
||||
}
|
||||
|
||||
impl Rotation {
|
||||
/// Rotate clockwise by 90 degrees.
|
||||
#[must_use]
|
||||
pub fn rotate_cw(self) -> Self {
|
||||
match self {
|
||||
Self::None => Self::Cw90, // 0 → 90
|
||||
Self::Cw90 => Self::Cw180, // 90 → 180
|
||||
Self::Cw180 => Self::Cw270, // 180 → 270
|
||||
Self::Cw270 => Self::None, // 270 → 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate counter-clockwise by 90 degrees.
|
||||
#[must_use]
|
||||
pub fn rotate_ccw(self) -> Self {
|
||||
match self {
|
||||
Self::None => Self::Cw270, // 0 → 270
|
||||
Self::Cw270 => Self::Cw180, // 270 → 180
|
||||
Self::Cw180 => Self::Cw90, // 180 → 90
|
||||
Self::Cw90 => Self::None, // 90 → 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to degrees (0, 90, 180, 270).
|
||||
#[must_use]
|
||||
pub fn to_degrees(self) -> i16 {
|
||||
match self {
|
||||
Self::None => 0,
|
||||
Self::Cw90 => 90,
|
||||
Self::Cw180 => 180,
|
||||
Self::Cw270 => 270,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flip direction for documents.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FlipDirection {
|
||||
/// Flip along the vertical axis (mirror left-right).
|
||||
Horizontal,
|
||||
/// Flip along the horizontal axis (mirror top-bottom).
|
||||
Vertical,
|
||||
}
|
||||
|
||||
/// Current transformation state of a document.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct TransformState {
|
||||
/// Current rotation.
|
||||
pub rotation: Rotation,
|
||||
/// Whether flipped horizontally.
|
||||
pub flip_h: bool,
|
||||
/// Whether flipped vertically.
|
||||
pub flip_v: bool,
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Output of a render operation.
|
||||
///
|
||||
/// Used as return type for the `Renderable::render()` trait method.
|
||||
/// Not constructed externally - only returned by trait implementations.
|
||||
#[allow(dead_code)]
|
||||
pub struct RenderOutput {
|
||||
/// Image handle for display.
|
||||
pub handle: ImageHandle,
|
||||
/// Rendered width in pixels.
|
||||
pub width: u32,
|
||||
/// Rendered height in pixels.
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
/// Document metadata/information.
|
||||
///
|
||||
/// Used as return type for the `Renderable::info()` trait method.
|
||||
/// Contains native dimensions and format description before any transformations.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DocumentInfo {
|
||||
/// Native width in pixels (before transforms).
|
||||
pub width: u32,
|
||||
/// Native height in pixels (before transforms).
|
||||
pub height: u32,
|
||||
/// Document format description.
|
||||
pub format: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Traits
|
||||
// ============================================================================
|
||||
|
||||
/// Trait for documents that can be rendered to an image.
|
||||
///
|
||||
/// This trait is used internally through type erasure via `DocumentContent`.
|
||||
/// The UI layer calls methods on `DocumentContent`, which delegates to the
|
||||
/// specific document type implementations (Raster, Vector, Portable).
|
||||
#[allow(dead_code)]
|
||||
pub trait Renderable {
|
||||
/// Render the document at the given scale factor.
|
||||
fn render(&mut self, scale: f64) -> DocResult<RenderOutput>;
|
||||
|
||||
/// Get document information (dimensions, format).
|
||||
fn info(&self) -> DocumentInfo;
|
||||
}
|
||||
|
||||
/// Trait for documents that support geometric transformations.
|
||||
pub trait Transformable {
|
||||
/// Apply a rotation state.
|
||||
fn rotate(&mut self, rotation: Rotation);
|
||||
|
||||
/// Flip in the given direction.
|
||||
fn flip(&mut self, direction: FlipDirection);
|
||||
|
||||
/// Get the current transformation state.
|
||||
fn transform_state(&self) -> TransformState;
|
||||
}
|
||||
|
||||
/// Trait for documents with multiple pages.
|
||||
pub trait MultiPage {
|
||||
/// Get total number of pages.
|
||||
fn page_count(&self) -> usize;
|
||||
|
||||
/// Get current page index (0-based).
|
||||
fn current_page(&self) -> usize;
|
||||
|
||||
/// Navigate to a specific page.
|
||||
fn goto_page(&mut self, page: u32) -> anyhow::Result<()>;
|
||||
fn go_to_page(&mut self, page: usize) -> DocResult<()>;
|
||||
}
|
||||
|
||||
/// Check if thumbnails are ready for display.
|
||||
/// Trait for multi-page documents that support thumbnail generation.
|
||||
///
|
||||
/// Currently implemented only by `PortableDocument` (PDF).
|
||||
/// Methods are called through `DocumentContent` type erasure.
|
||||
#[allow(dead_code)]
|
||||
pub trait MultiPageThumbnails: MultiPage {
|
||||
/// Get cached thumbnail for a page, if available.
|
||||
fn get_thumbnail(&self, page: usize) -> Option<ImageHandle>;
|
||||
|
||||
/// Check if all thumbnails are ready.
|
||||
fn thumbnails_ready(&self) -> bool;
|
||||
|
||||
/// Generate thumbnails (uses disk cache when available).
|
||||
fn generate_thumbnails(&mut self);
|
||||
/// Get count of thumbnails currently loaded.
|
||||
fn thumbnails_loaded(&self) -> usize;
|
||||
|
||||
/// Get cached thumbnail handle for a specific page.
|
||||
fn get_thumbnail(&self, page: u32) -> Option<ImageHandle>;
|
||||
/// Generate thumbnail for a single page. Returns next page to generate.
|
||||
fn generate_thumbnail_page(&mut self, page: usize) -> Option<usize>;
|
||||
|
||||
/// Generate all thumbnails (blocking).
|
||||
fn generate_all_thumbnails(&mut self);
|
||||
}
|
||||
|
||||
/// Re-export the image handle type for use by submodules.
|
||||
pub type ImageHandle = cosmic::iced::widget::image::Handle;
|
||||
// ============================================================================
|
||||
// Document Types
|
||||
// ============================================================================
|
||||
|
||||
/// Create an iced image handle from a DynamicImage.
|
||||
///
|
||||
/// This is the central function for converting rendered images to display handles.
|
||||
/// Used by raster, vector, and portable document types.
|
||||
pub fn create_image_handle(img: &image::DynamicImage) -> ImageHandle {
|
||||
let (w, h) = img.dimensions();
|
||||
let rgba = img.to_rgba8();
|
||||
let pixels = rgba.into_raw();
|
||||
ImageHandle::from_rgba(w, h, pixels)
|
||||
}
|
||||
|
||||
/// High-level classification of documents.
|
||||
/// Supported document kinds (for format detection).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DocumentKind {
|
||||
Raster,
|
||||
|
|
@ -63,7 +203,72 @@ pub enum DocumentKind {
|
|||
Portable,
|
||||
}
|
||||
|
||||
/// Unified document type used by the application.
|
||||
impl DocumentKind {
|
||||
/// Detect document kind from file path.
|
||||
#[must_use]
|
||||
pub fn from_path(path: &Path) -> Option<Self> {
|
||||
let ext = path.extension()?.to_str()?.to_lowercase();
|
||||
|
||||
// SVG
|
||||
if ext == "svg" || ext == "svgz" {
|
||||
return Some(Self::Vector);
|
||||
}
|
||||
|
||||
// PDF
|
||||
if ext == "pdf" {
|
||||
return Some(Self::Portable);
|
||||
}
|
||||
|
||||
// Raster: Check via cosmic/image-rs
|
||||
if CosmicImageFormat::from_path(path).is_ok() {
|
||||
return Some(Self::Raster);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DocumentKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Raster => write!(f, "Raster"),
|
||||
Self::Vector => write!(f, "Vector"),
|
||||
Self::Portable => write!(f, "Portable"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Image Handle Helper
|
||||
// ============================================================================
|
||||
|
||||
/// Handle for rendered images (compatible with cosmic/iced).
|
||||
pub type ImageHandle = cosmic::widget::image::Handle;
|
||||
|
||||
/// Create an image handle from RGBA pixel data.
|
||||
#[must_use]
|
||||
pub fn create_image_handle(pixels: Vec<u8>, width: u32, height: u32) -> ImageHandle {
|
||||
cosmic::widget::image::Handle::from_rgba(width, height, pixels)
|
||||
}
|
||||
|
||||
/// Create an image handle from a DynamicImage.
|
||||
#[must_use]
|
||||
pub fn create_image_handle_from_image(img: &image::DynamicImage) -> ImageHandle {
|
||||
let (width, height) = img.dimensions();
|
||||
let pixels = img.to_rgba8().into_raw();
|
||||
create_image_handle(pixels, width, height)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Document Content Enum
|
||||
// ============================================================================
|
||||
|
||||
/// Type-erased document content.
|
||||
///
|
||||
/// The application only holds one document at a time, so the size difference
|
||||
/// between variants (536 bytes for Vector vs 184 bytes for Portable) is acceptable.
|
||||
/// Boxing would add unnecessary indirection without measurable performance benefit.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum DocumentContent {
|
||||
Raster(RasterDocument),
|
||||
Vector(VectorDocument),
|
||||
|
|
@ -73,199 +278,212 @@ pub enum DocumentContent {
|
|||
impl fmt::Debug for DocumentContent {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
DocumentContent::Raster(_) => f.write_str("DocumentContent::Raster(..)"),
|
||||
DocumentContent::Vector(_) => f.write_str("DocumentContent::Vector(..)"),
|
||||
DocumentContent::Portable(_) => f.write_str("DocumentContent::Portable(..)"),
|
||||
Self::Raster(_) => write!(f, "DocumentContent::Raster(...)"),
|
||||
Self::Vector(_) => write!(f, "DocumentContent::Vector(...)"),
|
||||
Self::Portable(_) => write!(f, "DocumentContent::Portable(...)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DocumentKind {
|
||||
/// Derive document kind from file extension.
|
||||
///
|
||||
/// - `pdf` => Portable
|
||||
/// - `svg` => Vector
|
||||
/// - supported image extensions (via libcosmic/image_rs ImageFormat)
|
||||
/// => Raster
|
||||
///
|
||||
/// Returns `None` if the extension is not recognized as any supported kind.
|
||||
pub fn from_path(path: &Path) -> Option<Self> {
|
||||
let ext_os = path.extension()?;
|
||||
let ext_str = ext_os.to_str()?;
|
||||
let ext_lower = ext_str.to_ascii_lowercase();
|
||||
// ============================================================================
|
||||
// Trait Implementations for DocumentContent
|
||||
// ============================================================================
|
||||
|
||||
match ext_lower.as_str() {
|
||||
"pdf" => return Some(DocumentKind::Portable),
|
||||
"svg" => return Some(DocumentKind::Vector),
|
||||
_ => {}
|
||||
impl Renderable for DocumentContent {
|
||||
fn render(&mut self, scale: f64) -> DocResult<RenderOutput> {
|
||||
match self {
|
||||
Self::Raster(doc) => doc.render(scale),
|
||||
Self::Vector(doc) => doc.render(scale),
|
||||
Self::Portable(doc) => doc.render(scale),
|
||||
}
|
||||
}
|
||||
|
||||
// Ask libcosmic/image_rs if this extension corresponds to a known image
|
||||
// format. If yes, we treat it as a raster document.
|
||||
if CosmicImageFormat::from_extension(ext_os).is_some() {
|
||||
return Some(DocumentKind::Raster);
|
||||
fn info(&self) -> DocumentInfo {
|
||||
match self {
|
||||
Self::Raster(doc) => doc.info(),
|
||||
Self::Vector(doc) => doc.info(),
|
||||
Self::Portable(doc) => doc.info(),
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Transformable for DocumentContent {
|
||||
fn rotate(&mut self, rotation: Rotation) {
|
||||
match self {
|
||||
Self::Raster(doc) => doc.rotate(rotation),
|
||||
Self::Vector(doc) => doc.rotate(rotation),
|
||||
Self::Portable(doc) => doc.rotate(rotation),
|
||||
}
|
||||
}
|
||||
|
||||
fn flip(&mut self, direction: FlipDirection) {
|
||||
match self {
|
||||
Self::Raster(doc) => doc.flip(direction),
|
||||
Self::Vector(doc) => doc.flip(direction),
|
||||
Self::Portable(doc) => doc.flip(direction),
|
||||
}
|
||||
}
|
||||
|
||||
fn transform_state(&self) -> TransformState {
|
||||
match self {
|
||||
Self::Raster(doc) => doc.transform_state(),
|
||||
Self::Vector(doc) => doc.transform_state(),
|
||||
Self::Portable(doc) => doc.transform_state(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Convenience Methods for DocumentContent
|
||||
// ============================================================================
|
||||
|
||||
impl DocumentContent {
|
||||
/// Returns a cloneable image handle for rendering.
|
||||
///
|
||||
/// This is intentionally linear: every concrete document type
|
||||
/// owns some kind of `ImageHandle`, and the canvas can
|
||||
/// just call `doc.handle()` without additional branching.
|
||||
pub fn handle(&self) -> ImageHandle {
|
||||
match self {
|
||||
DocumentContent::Raster(doc) => doc.handle.clone(),
|
||||
DocumentContent::Vector(doc) => doc.handle.clone(),
|
||||
DocumentContent::Portable(doc) => doc.handle.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the native dimensions (width, height) of the document in pixels.
|
||||
///
|
||||
/// For raster images this is the actual pixel size.
|
||||
/// For vector/portable documents this is the rasterized size at default DPI.
|
||||
pub fn dimensions(&self) -> (u32, u32) {
|
||||
match self {
|
||||
DocumentContent::Raster(doc) => doc.dimensions(),
|
||||
DocumentContent::Vector(doc) => doc.dimensions(),
|
||||
DocumentContent::Portable(doc) => doc.dimensions(),
|
||||
}
|
||||
}
|
||||
/// Extract metadata from the document.
|
||||
/// Requires the file path for file size and EXIF extraction.
|
||||
pub fn extract_meta(&self, path: &Path) -> meta::DocumentMeta {
|
||||
match self {
|
||||
DocumentContent::Raster(doc) => doc.extract_meta(path),
|
||||
DocumentContent::Vector(doc) => doc.extract_meta(path),
|
||||
DocumentContent::Portable(doc) => doc.extract_meta(path),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate document 90 degrees clockwise.
|
||||
pub fn rotate_cw(&mut self) {
|
||||
match self {
|
||||
DocumentContent::Raster(doc) => doc.rotate_cw(),
|
||||
DocumentContent::Vector(doc) => doc.rotate_cw(),
|
||||
DocumentContent::Portable(doc) => doc.rotate_cw(),
|
||||
}
|
||||
let new_rotation = self.transform_state().rotation.rotate_cw();
|
||||
self.rotate(new_rotation);
|
||||
}
|
||||
|
||||
/// Rotate document 90 degrees counter-clockwise.
|
||||
pub fn rotate_ccw(&mut self) {
|
||||
match self {
|
||||
DocumentContent::Raster(doc) => doc.rotate_ccw(),
|
||||
DocumentContent::Vector(doc) => doc.rotate_ccw(),
|
||||
DocumentContent::Portable(doc) => doc.rotate_ccw(),
|
||||
}
|
||||
let new_rotation = self.transform_state().rotation.rotate_ccw();
|
||||
self.rotate(new_rotation);
|
||||
}
|
||||
|
||||
/// Flip document horizontally.
|
||||
pub fn flip_horizontal(&mut self) {
|
||||
match self {
|
||||
DocumentContent::Raster(doc) => doc.flip_horizontal(),
|
||||
DocumentContent::Vector(doc) => doc.flip_horizontal(),
|
||||
DocumentContent::Portable(doc) => doc.flip_horizontal(),
|
||||
}
|
||||
self.flip(FlipDirection::Horizontal);
|
||||
}
|
||||
|
||||
/// Flip document vertically.
|
||||
pub fn flip_vertical(&mut self) {
|
||||
self.flip(FlipDirection::Vertical);
|
||||
}
|
||||
|
||||
/// Get document kind.
|
||||
///
|
||||
/// Reserved for future use (format-specific optimizations, statistics).
|
||||
#[allow(dead_code)]
|
||||
#[must_use]
|
||||
pub fn kind(&self) -> DocumentKind {
|
||||
match self {
|
||||
DocumentContent::Raster(doc) => doc.flip_vertical(),
|
||||
DocumentContent::Vector(doc) => doc.flip_vertical(),
|
||||
DocumentContent::Portable(doc) => doc.flip_vertical(),
|
||||
Self::Raster(_) => DocumentKind::Raster,
|
||||
Self::Vector(_) => DocumentKind::Vector,
|
||||
Self::Portable(_) => DocumentKind::Portable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this document supports multiple pages.
|
||||
#[must_use]
|
||||
pub fn is_multi_page(&self) -> bool {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => doc.page_count() > 1,
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
_ => false,
|
||||
}
|
||||
self.page_count().is_some_and(|n| n > 1)
|
||||
}
|
||||
|
||||
/// Get page count if this is a multi-page document.
|
||||
pub fn page_count(&self) -> Option<u32> {
|
||||
/// Get page count if applicable.
|
||||
#[must_use]
|
||||
pub fn page_count(&self) -> Option<usize> {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => Some(doc.page_count()),
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
Self::Portable(doc) => Some(doc.page_count()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current page index if this is a multi-page document.
|
||||
pub fn current_page(&self) -> Option<u32> {
|
||||
/// Get current page index if applicable.
|
||||
#[must_use]
|
||||
pub fn current_page(&self) -> Option<usize> {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => Some(doc.current_page()),
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
Self::Portable(doc) => Some(doc.current_page()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to a specific page if this is a multi-page document.
|
||||
pub fn goto_page(&mut self, page: u32) -> anyhow::Result<()> {
|
||||
/// Navigate to a specific page.
|
||||
pub fn go_to_page(&mut self, page: usize) -> DocResult<()> {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => doc.goto_page(page),
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
Self::Portable(doc) => doc.go_to_page(page),
|
||||
_ => Err(anyhow::anyhow!("Document does not support multiple pages")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cached thumbnail handle for a specific page.
|
||||
pub fn get_thumbnail(&self, page: u32) -> Option<ImageHandle> {
|
||||
/// Get cached thumbnail for a page.
|
||||
#[must_use]
|
||||
pub fn get_thumbnail(&self, page: usize) -> Option<ImageHandle> {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => doc.get_thumbnail(page),
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
Self::Portable(doc) => doc.get_thumbnail(page),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if thumbnails are ready for display.
|
||||
/// Check if thumbnails are ready.
|
||||
#[must_use]
|
||||
pub fn thumbnails_ready(&self) -> bool {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => doc.thumbnails_ready(),
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
Self::Portable(doc) => doc.thumbnails_ready(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get number of thumbnails currently loaded.
|
||||
pub fn thumbnails_loaded(&self) -> u32 {
|
||||
/// Get count of loaded thumbnails.
|
||||
#[must_use]
|
||||
pub fn thumbnails_loaded(&self) -> usize {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => doc.thumbnails_loaded(),
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
Self::Portable(doc) => doc.thumbnails_loaded(),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a single thumbnail page. Returns next page to generate, or None if done.
|
||||
pub fn generate_thumbnail_page(&mut self, page: u32) -> Option<u32> {
|
||||
/// Generate thumbnail for a single page.
|
||||
pub fn generate_thumbnail_page(&mut self, page: usize) -> Option<usize> {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => doc.generate_thumbnail_page(page),
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
Self::Portable(doc) => doc.generate_thumbnail_page(page),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate all thumbnails at once (blocking).
|
||||
/// Generate all thumbnails (blocking).
|
||||
///
|
||||
/// Convenience wrapper for `MultiPageThumbnails::generate_all_thumbnails()`.
|
||||
/// 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() }
|
||||
}
|
||||
|
||||
/// Get current image handle for display.
|
||||
#[must_use]
|
||||
pub fn handle(&self) -> ImageHandle {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => doc.generate_thumbnails(),
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
_ => {}
|
||||
Self::Raster(doc) => doc.handle.clone(),
|
||||
Self::Vector(doc) => doc.handle.clone(),
|
||||
Self::Portable(doc) => doc.handle.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current document dimensions.
|
||||
#[must_use]
|
||||
pub fn dimensions(&self) -> (u32, u32) {
|
||||
match self {
|
||||
Self::Raster(doc) => doc.dimensions(),
|
||||
Self::Vector(doc) => doc.dimensions(),
|
||||
Self::Portable(doc) => doc.dimensions(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract document metadata.
|
||||
pub fn extract_meta(&self, path: &Path) -> meta::DocumentMeta {
|
||||
match self {
|
||||
Self::Raster(doc) => doc.extract_meta(path),
|
||||
Self::Vector(doc) => doc.extract_meta(path),
|
||||
Self::Portable(doc) => doc.extract_meta(path),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public Utilities
|
||||
// ============================================================================
|
||||
|
||||
/// Set an image file as desktop wallpaper.
|
||||
///
|
||||
/// Delegates to `utils::set_as_wallpaper` which tries multiple methods.
|
||||
pub fn set_as_wallpaper(path: &Path) {
|
||||
utils::set_as_wallpaper(path);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,11 @@ use cairo::{Context, Format, ImageSurface};
|
|||
use image::{imageops, DynamicImage, ImageReader};
|
||||
use poppler::PopplerDocument;
|
||||
|
||||
use super::{cache, ImageHandle};
|
||||
use crate::constant::{FULL_ROTATION, PDF_RENDER_SCALE, PDF_THUMBNAIL_SCALE, ROTATION_STEP};
|
||||
use super::{
|
||||
cache, DocResult, DocumentInfo, FlipDirection, ImageHandle, MultiPage, MultiPageThumbnails,
|
||||
Renderable, RenderOutput, Rotation, TransformState, Transformable,
|
||||
};
|
||||
use crate::constant::{PDF_RENDER_QUALITY, PDF_THUMBNAIL_SIZE};
|
||||
|
||||
/// Represents a portable document (PDF).
|
||||
pub struct PortableDocument {
|
||||
|
|
@ -20,11 +23,11 @@ pub struct PortableDocument {
|
|||
/// Path to the source file (for caching).
|
||||
source_path: PathBuf,
|
||||
/// Total number of pages.
|
||||
page_count: u32,
|
||||
num_pages: usize,
|
||||
/// Current page index (0-based).
|
||||
current_page: u32,
|
||||
/// Rotation in degrees (0, 90, 180, 270).
|
||||
pub rotation: i16,
|
||||
page_index: usize,
|
||||
/// Current transformation state.
|
||||
transform: TransformState,
|
||||
/// Current rendered page as image.
|
||||
pub rendered: DynamicImage,
|
||||
/// Image handle for display.
|
||||
|
|
@ -39,58 +42,47 @@ impl PortableDocument {
|
|||
let document = PopplerDocument::new_from_file(path, None)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse PDF: {}", e))?;
|
||||
|
||||
let page_count = document.get_n_pages() as u32;
|
||||
if page_count == 0 {
|
||||
let num_pages = document.get_n_pages();
|
||||
if num_pages == 0 {
|
||||
return Err(anyhow::anyhow!("PDF has no pages"));
|
||||
}
|
||||
|
||||
let rendered = Self::render_page(&document, 0, 0)?;
|
||||
let handle = super::create_image_handle(&rendered);
|
||||
let rendered = Self::render_page(&document, 0, Rotation::None)?;
|
||||
let handle = super::create_image_handle_from_image(&rendered);
|
||||
|
||||
Ok(Self {
|
||||
document,
|
||||
source_path: path.to_path_buf(),
|
||||
page_count,
|
||||
current_page: 0,
|
||||
rotation: 0,
|
||||
num_pages,
|
||||
page_index: 0,
|
||||
transform: TransformState::default(),
|
||||
rendered,
|
||||
handle,
|
||||
thumbnail_cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if all thumbnails are ready.
|
||||
pub fn thumbnails_ready(&self) -> bool {
|
||||
self.thumbnail_cache
|
||||
.as_ref()
|
||||
.map(|c| c.len() as u32 >= self.page_count)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Get the number of thumbnails currently loaded.
|
||||
pub fn thumbnails_loaded(&self) -> u32 {
|
||||
self.thumbnail_cache
|
||||
.as_ref()
|
||||
.map(|c| c.len() as u32)
|
||||
.unwrap_or(0)
|
||||
pub fn thumbnails_loaded(&self) -> usize {
|
||||
self.thumbnail_cache.as_ref().map_or(0, Vec::len)
|
||||
}
|
||||
|
||||
/// Initialize thumbnail cache (empty, ready for incremental loading).
|
||||
pub fn init_thumbnail_cache(&mut self) {
|
||||
fn init_thumbnail_cache(&mut self) {
|
||||
if self.thumbnail_cache.is_none() {
|
||||
self.thumbnail_cache = Some(Vec::with_capacity(self.page_count as usize));
|
||||
self.thumbnail_cache = Some(Vec::with_capacity(self.num_pages));
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a single thumbnail page. Returns the next page to generate, or None if done.
|
||||
pub fn generate_thumbnail_page(&mut self, page: u32) -> Option<u32> {
|
||||
pub fn generate_thumbnail_page(&mut self, page: usize) -> Option<usize> {
|
||||
// Initialize cache if needed.
|
||||
self.init_thumbnail_cache();
|
||||
|
||||
// Check if we should generate this page.
|
||||
let should_generate = {
|
||||
let cache = self.thumbnail_cache.as_ref()?;
|
||||
page as usize >= cache.len() && page < self.page_count
|
||||
page >= cache.len() && page < self.num_pages
|
||||
};
|
||||
|
||||
if should_generate {
|
||||
|
|
@ -102,34 +94,24 @@ impl PortableDocument {
|
|||
|
||||
// Return next page if not done.
|
||||
let next = page + 1;
|
||||
if next < self.page_count {
|
||||
if next < self.num_pages {
|
||||
Some(next)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate all thumbnails at once (legacy, blocking).
|
||||
pub fn generate_thumbnails(&mut self) {
|
||||
if self.thumbnails_ready() {
|
||||
return;
|
||||
}
|
||||
self.init_thumbnail_cache();
|
||||
for page in 0..self.page_count {
|
||||
self.generate_thumbnail_page(page);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load thumbnail from cache or generate and cache it.
|
||||
fn load_or_generate_thumbnail(&self, page: u32) -> ImageHandle {
|
||||
fn load_or_generate_thumbnail(&self, page: usize) -> ImageHandle {
|
||||
if let Some(handle) = cache::load_thumbnail(&self.source_path, page) {
|
||||
return handle;
|
||||
}
|
||||
|
||||
match Self::render_page_at_scale(&self.document, page, 0, PDF_THUMBNAIL_SCALE) {
|
||||
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(&img)
|
||||
super::create_image_handle_from_image(&img)
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to generate thumbnail for page {}: {}", page, e);
|
||||
|
|
@ -141,32 +123,35 @@ impl PortableDocument {
|
|||
/// Render a specific page from the document to an image.
|
||||
fn render_page(
|
||||
document: &PopplerDocument,
|
||||
page_index: u32,
|
||||
rotation: i16,
|
||||
page_index: usize,
|
||||
rotation: Rotation,
|
||||
) -> anyhow::Result<DynamicImage> {
|
||||
Self::render_page_at_scale(document, page_index, rotation, PDF_RENDER_SCALE)
|
||||
Self::render_page_at_scale(document, page_index, rotation, PDF_RENDER_QUALITY)
|
||||
}
|
||||
|
||||
/// Render a specific page at a given scale.
|
||||
fn render_page_at_scale(
|
||||
document: &PopplerDocument,
|
||||
page_index: u32,
|
||||
rotation: i16,
|
||||
page_index: usize,
|
||||
rotation: Rotation,
|
||||
scale: f64,
|
||||
) -> anyhow::Result<DynamicImage> {
|
||||
let page = document
|
||||
.get_page(page_index as usize)
|
||||
.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();
|
||||
|
||||
let (width, height) = if rotation == 90 || rotation == 270 {
|
||||
let (width, height) = if rotation_degrees == 90 || rotation_degrees == 270 {
|
||||
(page_height, page_width)
|
||||
} else {
|
||||
(page_width, page_height)
|
||||
};
|
||||
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let scaled_width = (width * scale) as i32;
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let scaled_height = (height * scale) as i32;
|
||||
|
||||
let surface = ImageSurface::create(Format::ARgb32, scaled_width, scaled_height)
|
||||
|
|
@ -181,11 +166,11 @@ impl PortableDocument {
|
|||
|
||||
context.scale(scale, scale);
|
||||
|
||||
if rotation != 0 {
|
||||
if rotation != Rotation::None {
|
||||
let center_x = width / 2.0;
|
||||
let center_y = height / 2.0;
|
||||
context.translate(center_x, center_y);
|
||||
context.rotate(f64::from(rotation) * std::f64::consts::PI / 180.0);
|
||||
context.rotate(f64::from(rotation_degrees) * std::f64::consts::PI / 180.0);
|
||||
context.translate(-page_width / 2.0, -page_height / 2.0);
|
||||
}
|
||||
|
||||
|
|
@ -208,10 +193,17 @@ impl PortableDocument {
|
|||
Ok(image)
|
||||
}
|
||||
|
||||
/// Re-render the current page.
|
||||
/// Re-render the current page with current transform.
|
||||
fn rerender(&mut self) {
|
||||
match Self::render_page(&self.document, self.current_page, self.rotation) {
|
||||
Ok(rendered) => {
|
||||
match Self::render_page(&self.document, self.page_index, self.transform.rotation) {
|
||||
Ok(mut rendered) => {
|
||||
// Apply flip transformations to the rendered result
|
||||
if self.transform.flip_h {
|
||||
rendered = DynamicImage::ImageRgba8(imageops::flip_horizontal(&rendered));
|
||||
}
|
||||
if self.transform.flip_v {
|
||||
rendered = DynamicImage::ImageRgba8(imageops::flip_vertical(&rendered));
|
||||
}
|
||||
self.rendered = rendered;
|
||||
self.refresh_handle();
|
||||
}
|
||||
|
|
@ -222,8 +214,8 @@ impl PortableDocument {
|
|||
}
|
||||
|
||||
/// Rebuild the handle after mutating `rendered`.
|
||||
pub fn refresh_handle(&mut self) {
|
||||
self.handle = super::create_image_handle(&self.rendered);
|
||||
fn refresh_handle(&mut self) {
|
||||
self.handle = super::create_image_handle_from_image(&self.rendered);
|
||||
}
|
||||
|
||||
/// Returns the dimensions of the currently rendered page.
|
||||
|
|
@ -231,24 +223,11 @@ impl PortableDocument {
|
|||
(self.rendered.width(), self.rendered.height())
|
||||
}
|
||||
|
||||
/// Navigate to a specific page.
|
||||
pub fn goto_page(&mut self, page: u32) -> anyhow::Result<()> {
|
||||
if page >= self.page_count {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Page {} out of range (0-{})",
|
||||
page,
|
||||
self.page_count - 1
|
||||
));
|
||||
}
|
||||
self.current_page = page;
|
||||
self.rerender();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Navigate to the next page.
|
||||
#[allow(dead_code)]
|
||||
pub fn next_page(&mut self) -> bool {
|
||||
if self.current_page + 1 < self.page_count {
|
||||
self.current_page += 1;
|
||||
if self.page_index + 1 < self.num_pages {
|
||||
self.page_index += 1;
|
||||
self.rerender();
|
||||
true
|
||||
} else {
|
||||
|
|
@ -257,9 +236,10 @@ impl PortableDocument {
|
|||
}
|
||||
|
||||
/// Navigate to the previous page.
|
||||
#[allow(dead_code)]
|
||||
pub fn prev_page(&mut self) -> bool {
|
||||
if self.current_page > 0 {
|
||||
self.current_page -= 1;
|
||||
if self.page_index > 0 {
|
||||
self.page_index -= 1;
|
||||
self.rerender();
|
||||
true
|
||||
} else {
|
||||
|
|
@ -267,51 +247,109 @@ impl PortableDocument {
|
|||
}
|
||||
}
|
||||
|
||||
/// Rotate 90 degrees clockwise.
|
||||
pub fn rotate_cw(&mut self) {
|
||||
self.rotation = (self.rotation + ROTATION_STEP).rem_euclid(FULL_ROTATION);
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
/// Rotate 90 degrees counter-clockwise.
|
||||
pub fn rotate_ccw(&mut self) {
|
||||
self.rotation = (self.rotation - ROTATION_STEP).rem_euclid(FULL_ROTATION);
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
/// Flip horizontally.
|
||||
pub fn flip_horizontal(&mut self) {
|
||||
self.rendered = DynamicImage::ImageRgba8(imageops::flip_horizontal(&self.rendered));
|
||||
self.refresh_handle();
|
||||
}
|
||||
|
||||
/// Flip vertically.
|
||||
pub fn flip_vertical(&mut self) {
|
||||
self.rendered = DynamicImage::ImageRgba8(imageops::flip_vertical(&self.rendered));
|
||||
self.refresh_handle();
|
||||
}
|
||||
|
||||
/// Extract metadata for this portable document.
|
||||
pub fn extract_meta(&self, path: &Path) -> super::meta::DocumentMeta {
|
||||
let (width, height) = self.dimensions();
|
||||
super::meta::build_portable_meta(path, width, height, self.page_count)
|
||||
}
|
||||
|
||||
/// Get total page count.
|
||||
pub fn page_count(&self) -> u32 {
|
||||
self.page_count
|
||||
}
|
||||
|
||||
/// Get current page index (0-based).
|
||||
pub fn current_page(&self) -> u32 {
|
||||
self.current_page
|
||||
}
|
||||
|
||||
/// Get cached thumbnail handle for a specific page.
|
||||
/// Returns None if thumbnails not yet generated.
|
||||
pub fn get_thumbnail(&self, page: u32) -> Option<ImageHandle> {
|
||||
self.thumbnail_cache
|
||||
.as_ref()
|
||||
.and_then(|cache| cache.get(page as usize).cloned())
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
super::meta::build_portable_meta(path, width, height, self.num_pages as u32)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Trait Implementations
|
||||
// ============================================================================
|
||||
|
||||
impl Renderable for PortableDocument {
|
||||
fn render(&mut self, _scale: f64) -> DocResult<RenderOutput> {
|
||||
// PDF rendering quality is fixed for now (PDF_RENDER_QUALITY)
|
||||
let (width, height) = self.dimensions();
|
||||
Ok(RenderOutput {
|
||||
handle: self.handle.clone(),
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
fn info(&self) -> DocumentInfo {
|
||||
let (width, height) = self.dimensions();
|
||||
DocumentInfo {
|
||||
width,
|
||||
height,
|
||||
format: "PDF".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Transformable for PortableDocument {
|
||||
fn rotate(&mut self, rotation: Rotation) {
|
||||
self.transform.rotation = rotation;
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
fn flip(&mut self, direction: FlipDirection) {
|
||||
match direction {
|
||||
FlipDirection::Horizontal => self.transform.flip_h = !self.transform.flip_h,
|
||||
FlipDirection::Vertical => self.transform.flip_v = !self.transform.flip_v,
|
||||
}
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
fn transform_state(&self) -> TransformState {
|
||||
self.transform
|
||||
}
|
||||
}
|
||||
|
||||
impl MultiPage for PortableDocument {
|
||||
fn page_count(&self) -> usize {
|
||||
self.num_pages
|
||||
}
|
||||
|
||||
fn current_page(&self) -> usize {
|
||||
self.page_index
|
||||
}
|
||||
|
||||
fn go_to_page(&mut self, page: usize) -> DocResult<()> {
|
||||
if page >= self.num_pages {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Page {} out of range (0-{})",
|
||||
page,
|
||||
self.num_pages - 1
|
||||
));
|
||||
}
|
||||
self.page_index = page;
|
||||
self.rerender();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl MultiPageThumbnails for PortableDocument {
|
||||
fn thumbnails_ready(&self) -> bool {
|
||||
self.thumbnail_cache
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.len() >= self.num_pages)
|
||||
}
|
||||
|
||||
fn thumbnails_loaded(&self) -> usize {
|
||||
PortableDocument::thumbnails_loaded(self)
|
||||
}
|
||||
|
||||
fn generate_thumbnail_page(&mut self, page: usize) -> Option<usize> {
|
||||
PortableDocument::generate_thumbnail_page(self, page)
|
||||
}
|
||||
|
||||
fn generate_all_thumbnails(&mut self) {
|
||||
if self.thumbnails_ready() {
|
||||
return;
|
||||
}
|
||||
self.init_thumbnail_cache();
|
||||
for page in 0..self.num_pages {
|
||||
self.generate_thumbnail_page(page);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_thumbnail(&self, page: usize) -> Option<ImageHandle> {
|
||||
self.thumbnail_cache
|
||||
.as_ref()
|
||||
.and_then(|cache| cache.get(page).cloned())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,27 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/app/document/raster.rs
|
||||
//
|
||||
// Raster image document support (PNG, JPEG, WebP, etc.).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use image::{imageops, DynamicImage, GenericImageView, ImageReader};
|
||||
|
||||
use super::ImageHandle;
|
||||
use super::{
|
||||
DocResult, DocumentInfo, FlipDirection, ImageHandle, Renderable, RenderOutput, Rotation,
|
||||
TransformState, Transformable,
|
||||
};
|
||||
|
||||
/// Represents a raster image document (PNG, JPEG, WebP, ...).
|
||||
pub struct RasterDocument {
|
||||
/// The decoded image document.
|
||||
document: DynamicImage,
|
||||
/// Native width (original, before transforms).
|
||||
native_width: u32,
|
||||
/// Native height (original, before transforms).
|
||||
native_height: u32,
|
||||
/// Current transformation state.
|
||||
transform: TransformState,
|
||||
/// Cached handle for rendering.
|
||||
pub handle: ImageHandle,
|
||||
}
|
||||
|
|
@ -19,53 +30,103 @@ impl RasterDocument {
|
|||
/// Load a raster document from disk.
|
||||
pub fn open(path: &Path) -> image::ImageResult<Self> {
|
||||
let document = ImageReader::open(path)?.decode()?;
|
||||
let handle = super::create_image_handle(&document);
|
||||
let (native_width, native_height) = document.dimensions();
|
||||
let handle = super::create_image_handle_from_image(&document);
|
||||
|
||||
Ok(Self { document, handle })
|
||||
Ok(Self {
|
||||
document,
|
||||
native_width,
|
||||
native_height,
|
||||
transform: TransformState::default(),
|
||||
handle,
|
||||
})
|
||||
}
|
||||
|
||||
/// Rebuild the handle after mutating `document`.
|
||||
pub fn refresh_handle(&mut self) {
|
||||
self.handle = super::create_image_handle(&self.document);
|
||||
fn refresh_handle(&mut self) {
|
||||
self.handle = super::create_image_handle_from_image(&self.document);
|
||||
}
|
||||
|
||||
/// Returns the native pixel dimensions (width, height).
|
||||
/// Returns the current pixel dimensions (width, height) after transforms.
|
||||
pub fn dimensions(&self) -> (u32, u32) {
|
||||
self.document.dimensions()
|
||||
}
|
||||
|
||||
/// Save the current document to disk.
|
||||
#[allow(dead_code)]
|
||||
pub fn save(&self, path: &Path) -> image::ImageResult<()> {
|
||||
self.document.save(path)
|
||||
}
|
||||
|
||||
/// Extract metadata for this raster document.
|
||||
pub fn extract_meta(&self, path: &Path) -> super::meta::DocumentMeta {
|
||||
let (width, height) = self.dimensions();
|
||||
super::meta::build_raster_meta(path, &self.document, width, height)
|
||||
}
|
||||
|
||||
/// Rotate 90 degrees clockwise.
|
||||
pub fn rotate_cw(&mut self) {
|
||||
self.document = DynamicImage::ImageRgba8(imageops::rotate90(&self.document));
|
||||
self.refresh_handle();
|
||||
}
|
||||
|
||||
/// Rotate 90 degrees counter-clockwise.
|
||||
pub fn rotate_ccw(&mut self) {
|
||||
self.document = DynamicImage::ImageRgba8(imageops::rotate270(&self.document));
|
||||
self.refresh_handle();
|
||||
}
|
||||
|
||||
/// Flip horizontally.
|
||||
pub fn flip_horizontal(&mut self) {
|
||||
self.document = DynamicImage::ImageRgba8(imageops::flip_horizontal(&self.document));
|
||||
self.refresh_handle();
|
||||
}
|
||||
|
||||
/// Flip vertically.
|
||||
pub fn flip_vertical(&mut self) {
|
||||
self.document = DynamicImage::ImageRgba8(imageops::flip_vertical(&self.document));
|
||||
self.refresh_handle();
|
||||
super::meta::build_raster_meta(path, &self.document, self.native_width, self.native_height)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Trait Implementations
|
||||
// ============================================================================
|
||||
|
||||
impl Renderable for RasterDocument {
|
||||
fn render(&mut self, _scale: f64) -> DocResult<RenderOutput> {
|
||||
// Raster images don't re-render at different scales (lossy),
|
||||
// we just return the current handle.
|
||||
let (width, height) = self.dimensions();
|
||||
Ok(RenderOutput {
|
||||
handle: self.handle.clone(),
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
fn info(&self) -> DocumentInfo {
|
||||
DocumentInfo {
|
||||
width: self.native_width,
|
||||
height: self.native_height,
|
||||
format: "Raster".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Transformable for RasterDocument {
|
||||
fn rotate(&mut self, rotation: Rotation) {
|
||||
let current_deg = self.transform.rotation.to_degrees();
|
||||
let new_deg = rotation.to_degrees();
|
||||
let diff_deg = (new_deg - current_deg + 360) % 360;
|
||||
|
||||
match diff_deg {
|
||||
0 => {}
|
||||
90 => {
|
||||
self.document = DynamicImage::ImageRgba8(imageops::rotate90(&self.document));
|
||||
}
|
||||
180 => {
|
||||
self.document = DynamicImage::ImageRgba8(imageops::rotate180(&self.document));
|
||||
}
|
||||
270 => {
|
||||
self.document = DynamicImage::ImageRgba8(imageops::rotate270(&self.document));
|
||||
}
|
||||
_ => unreachable!("Invalid rotation diff: {}", diff_deg),
|
||||
}
|
||||
self.transform.rotation = rotation;
|
||||
self.refresh_handle();
|
||||
}
|
||||
|
||||
fn flip(&mut self, direction: FlipDirection) {
|
||||
match direction {
|
||||
FlipDirection::Horizontal => {
|
||||
self.document = DynamicImage::ImageRgba8(imageops::flip_horizontal(&self.document));
|
||||
self.transform.flip_h = !self.transform.flip_h;
|
||||
}
|
||||
FlipDirection::Vertical => {
|
||||
self.document = DynamicImage::ImageRgba8(imageops::flip_vertical(&self.document));
|
||||
self.transform.flip_v = !self.transform.flip_v;
|
||||
}
|
||||
}
|
||||
self.refresh_handle();
|
||||
}
|
||||
|
||||
fn transform_state(&self) -> TransformState {
|
||||
self.transform
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,19 +9,11 @@ use image::{imageops, DynamicImage, RgbaImage};
|
|||
use resvg::tiny_skia::{self, Pixmap};
|
||||
use resvg::usvg::{Options, Tree};
|
||||
|
||||
use super::ImageHandle;
|
||||
use crate::constant::{FULL_ROTATION, MIN_PIXMAP_SIZE, ROTATION_STEP};
|
||||
|
||||
/// Accumulated transformations for a vector document.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct VectorTransform {
|
||||
/// Rotation in degrees (0, 90, 180, 270).
|
||||
pub rotation: i16,
|
||||
/// Horizontal flip.
|
||||
pub flip_h: bool,
|
||||
/// Vertical flip.
|
||||
pub flip_v: bool,
|
||||
}
|
||||
use super::{
|
||||
DocResult, DocumentInfo, FlipDirection, ImageHandle, Renderable, RenderOutput, Rotation,
|
||||
TransformState, Transformable,
|
||||
};
|
||||
use crate::constant::MIN_PIXMAP_SIZE;
|
||||
|
||||
/// Represents a vector document such as SVG.
|
||||
pub struct VectorDocument {
|
||||
|
|
@ -32,9 +24,9 @@ pub struct VectorDocument {
|
|||
/// Native height of the SVG (from viewBox or height attribute).
|
||||
native_height: u32,
|
||||
/// Current render scale (1.0 = native size).
|
||||
current_scale: f32,
|
||||
current_scale: f64,
|
||||
/// Accumulated transformations.
|
||||
transform: VectorTransform,
|
||||
transform: TransformState,
|
||||
/// Rasterized image at the current scale.
|
||||
pub rendered: DynamicImage,
|
||||
/// Image handle for display.
|
||||
|
|
@ -59,12 +51,12 @@ impl VectorDocument {
|
|||
let native_width = size.width().ceil() as u32;
|
||||
let native_height = size.height().ceil() as u32;
|
||||
|
||||
let transform = VectorTransform::default();
|
||||
let transform = TransformState::default();
|
||||
|
||||
// Render at native scale (1.0).
|
||||
let (rendered, width, height) =
|
||||
render_document(&document, native_width, native_height, 1.0, &transform)?;
|
||||
let handle = super::create_image_handle(&rendered);
|
||||
let handle = super::create_image_handle_from_image(&rendered);
|
||||
|
||||
Ok(Self {
|
||||
document,
|
||||
|
|
@ -86,9 +78,10 @@ impl VectorDocument {
|
|||
|
||||
/// Re-render the SVG at a new scale, preserving transformations.
|
||||
/// Returns true if re-rendering occurred.
|
||||
pub fn render_at_scale(&mut self, scale: f32) -> bool {
|
||||
#[allow(dead_code)]
|
||||
pub fn render_at_scale(&mut self, scale: f64) -> bool {
|
||||
// Skip if scale hasn't changed
|
||||
if (self.current_scale - scale).abs() < f32::EPSILON {
|
||||
if (self.current_scale - scale).abs() < f64::EPSILON {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -104,7 +97,7 @@ impl VectorDocument {
|
|||
self.rendered = rendered;
|
||||
self.width = width;
|
||||
self.height = height;
|
||||
self.handle = super::create_image_handle(&self.rendered);
|
||||
self.handle = super::create_image_handle_from_image(&self.rendered);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
@ -114,32 +107,6 @@ impl VectorDocument {
|
|||
}
|
||||
}
|
||||
|
||||
/// Rotate 90 degrees clockwise.
|
||||
pub fn rotate_cw(&mut self) {
|
||||
self.transform.rotation =
|
||||
(self.transform.rotation + ROTATION_STEP).rem_euclid(FULL_ROTATION);
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
/// Rotate 90 degrees counter-clockwise.
|
||||
pub fn rotate_ccw(&mut self) {
|
||||
self.transform.rotation =
|
||||
(self.transform.rotation - ROTATION_STEP).rem_euclid(FULL_ROTATION);
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
/// Flip horizontally.
|
||||
pub fn flip_horizontal(&mut self) {
|
||||
self.transform.flip_h = !self.transform.flip_h;
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
/// Flip vertically.
|
||||
pub fn flip_vertical(&mut self) {
|
||||
self.transform.flip_v = !self.transform.flip_v;
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
/// Re-render with current scale and transform.
|
||||
fn rerender(&mut self) {
|
||||
if let Ok((rendered, width, height)) = render_document(
|
||||
|
|
@ -152,7 +119,7 @@ impl VectorDocument {
|
|||
self.rendered = rendered;
|
||||
self.width = width;
|
||||
self.height = height;
|
||||
self.handle = super::create_image_handle(&self.rendered);
|
||||
self.handle = super::create_image_handle_from_image(&self.rendered);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -163,21 +130,67 @@ impl VectorDocument {
|
|||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Trait Implementations
|
||||
// ============================================================================
|
||||
|
||||
impl Renderable for VectorDocument {
|
||||
fn render(&mut self, scale: f64) -> DocResult<RenderOutput> {
|
||||
self.render_at_scale(scale);
|
||||
Ok(RenderOutput {
|
||||
handle: self.handle.clone(),
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
})
|
||||
}
|
||||
|
||||
fn info(&self) -> DocumentInfo {
|
||||
DocumentInfo {
|
||||
width: self.native_width,
|
||||
height: self.native_height,
|
||||
format: "SVG".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Transformable for VectorDocument {
|
||||
fn rotate(&mut self, rotation: Rotation) {
|
||||
self.transform.rotation = rotation;
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
fn flip(&mut self, direction: FlipDirection) {
|
||||
match direction {
|
||||
FlipDirection::Horizontal => self.transform.flip_h = !self.transform.flip_h,
|
||||
FlipDirection::Vertical => self.transform.flip_v = !self.transform.flip_v,
|
||||
}
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
fn transform_state(&self) -> TransformState {
|
||||
self.transform
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the SVG document at a given scale with transformations.
|
||||
fn render_document(
|
||||
document: &Tree,
|
||||
native_width: u32,
|
||||
native_height: u32,
|
||||
scale: f32,
|
||||
transform: &VectorTransform,
|
||||
scale: f64,
|
||||
transform: &TransformState,
|
||||
) -> anyhow::Result<(DynamicImage, u32, u32)> {
|
||||
let width = (((native_width as f32) * scale).ceil() as u32).max(MIN_PIXMAP_SIZE);
|
||||
let height = (((native_height as f32) * scale).ceil() as u32).max(MIN_PIXMAP_SIZE);
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
let width = (((native_width as f64) * 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 mut pixmap =
|
||||
Pixmap::new(width, height).ok_or_else(|| anyhow::anyhow!("Failed to create pixmap"))?;
|
||||
|
||||
let ts = tiny_skia::Transform::from_scale(scale, scale);
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let scale_f32 = scale as f32;
|
||||
let ts = tiny_skia::Transform::from_scale(scale_f32, scale_f32);
|
||||
resvg::render(document, ts, &mut pixmap.as_mut());
|
||||
|
||||
let mut image = pixmap_to_dynamic_image(&pixmap);
|
||||
|
|
@ -192,10 +205,10 @@ fn render_document(
|
|||
|
||||
// Apply rotation
|
||||
image = match transform.rotation {
|
||||
90 => DynamicImage::ImageRgba8(imageops::rotate90(&image)),
|
||||
180 => DynamicImage::ImageRgba8(imageops::rotate180(&image)),
|
||||
270 => DynamicImage::ImageRgba8(imageops::rotate270(&image)),
|
||||
_ => image,
|
||||
Rotation::Cw90 => DynamicImage::ImageRgba8(imageops::rotate90(&image)),
|
||||
Rotation::Cw180 => DynamicImage::ImageRgba8(imageops::rotate180(&image)),
|
||||
Rotation::Cw270 => DynamicImage::ImageRgba8(imageops::rotate270(&image)),
|
||||
Rotation::None => image,
|
||||
};
|
||||
|
||||
let final_width = image.width();
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ pub enum AppMessage {
|
|||
OpenPath(PathBuf),
|
||||
NextDocument,
|
||||
PrevDocument,
|
||||
GotoPage(u32),
|
||||
GenerateThumbnailPage(u32),
|
||||
GotoPage(usize),
|
||||
GenerateThumbnailPage(usize),
|
||||
|
||||
// Transformations.
|
||||
RotateCW,
|
||||
|
|
|
|||
|
|
@ -166,11 +166,11 @@ impl cosmic::Application for Noctua {
|
|||
}
|
||||
|
||||
fn header_start(&self) -> Vec<Element<'_, Self::Message>> {
|
||||
view::header::header_start(&self.model)
|
||||
view::header::start(&self.model)
|
||||
}
|
||||
|
||||
fn header_end(&self) -> Vec<Element<'_, Self::Message>> {
|
||||
view::header::header_end(&self.model)
|
||||
view::header::end(&self.model)
|
||||
}
|
||||
|
||||
fn view(&self) -> Element<'_, Self::Message> {
|
||||
|
|
@ -182,7 +182,7 @@ impl cosmic::Application for Noctua {
|
|||
return None;
|
||||
}
|
||||
Some(context_drawer::context_drawer(
|
||||
view::panels::properties_panel(&self.model),
|
||||
view::panels::view(&self.model),
|
||||
AppMessage::ToggleContextPage(ContextPage::Properties),
|
||||
))
|
||||
}
|
||||
|
|
@ -307,7 +307,7 @@ fn thumbnail_refresh_subscription(app: &Noctua) -> Subscription<AppMessage> {
|
|||
.model
|
||||
.document
|
||||
.as_ref()
|
||||
.map_or(false, |doc| doc.is_multi_page() && !doc.thumbnails_ready());
|
||||
.is_some_and(|doc| doc.is_multi_page() && !doc.thumbnails_ready());
|
||||
|
||||
if needs_refresh {
|
||||
time::every(Duration::from_millis(100)).map(|_| AppMessage::RefreshView)
|
||||
|
|
|
|||
|
|
@ -39,17 +39,16 @@ pub fn update(model: &mut AppModel, msg: &AppMessage, config: &AppConfig) -> Upd
|
|||
}
|
||||
|
||||
AppMessage::GotoPage(page) => {
|
||||
if let Some(doc) = &mut model.document {
|
||||
if let Err(e) = doc.goto_page(*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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Thumbnail generation -------------------------------------------------
|
||||
AppMessage::GenerateThumbnailPage(page) => {
|
||||
if let Some(doc) = &mut model.document {
|
||||
if let Some(next_page) = doc.generate_thumbnail_page(*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))
|
||||
|
|
@ -57,7 +56,6 @@ pub fn update(model: &mut AppModel, msg: &AppMessage, config: &AppConfig) -> Upd
|
|||
Task::done(Action::App(AppMessage::RefreshView)),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppMessage::RefreshView => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/app/view/header.rs
|
||||
//
|
||||
// Header bar buttons (navigation, rotation, flip).
|
||||
// Header bar content (navigation, rotation, flip).
|
||||
|
||||
use cosmic::iced::{Alignment, Length};
|
||||
use cosmic::iced::Length;
|
||||
use cosmic::widget::{button, horizontal_space, icon, row};
|
||||
use cosmic::Element;
|
||||
|
||||
|
|
@ -11,8 +11,8 @@ use crate::app::message::AppMessage;
|
|||
use crate::app::model::AppModel;
|
||||
use crate::app::ContextPage;
|
||||
|
||||
/// Build the left side of the header bar.
|
||||
pub fn header_start(model: &AppModel) -> Vec<Element<'_, AppMessage>> {
|
||||
/// Build the start (left) side of the header bar.
|
||||
pub fn start(model: &AppModel) -> Vec<Element<'_, AppMessage>> {
|
||||
let has_doc = model.document.is_some();
|
||||
|
||||
// Left: Nav toggle + Navigation
|
||||
|
|
@ -55,8 +55,8 @@ pub fn header_start(model: &AppModel) -> Vec<Element<'_, AppMessage>> {
|
|||
]
|
||||
}
|
||||
|
||||
/// Build the right side of the header bar.
|
||||
pub fn header_end(_model: &AppModel) -> Vec<Element<'_, AppMessage>> {
|
||||
/// Build the end (right) side of the header bar.
|
||||
pub fn end(_model: &AppModel) -> Vec<Element<'_, AppMessage>> {
|
||||
vec![
|
||||
// Info panel toggle
|
||||
button::icon(icon::from_name("dialog-information-symbolic"))
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ use cosmic::iced::{ContentFit, Element, Length, Pixels, Point, Radians, Rectangl
|
|||
|
||||
use crate::constant::{OFFSET_EPSILON, SCALE_EPSILON};
|
||||
|
||||
/// Callback type for notifying viewer state changes (scale, offset_x, offset_y).
|
||||
type StateChangeCallback<Message> = Box<dyn Fn(f32, f32, f32) -> Message>;
|
||||
|
||||
/// A frame that displays an image with the ability to zoom in/out and pan.
|
||||
#[allow(missing_debug_implementations)]
|
||||
pub struct Viewer<Handle, Message> {
|
||||
|
|
@ -32,7 +35,7 @@ pub struct Viewer<Handle, Message> {
|
|||
/// Optional external state to override internal state (scale, offset)
|
||||
external_state: Option<(f32, Vector)>,
|
||||
/// Optional callback to notify state changes
|
||||
on_state_change: Option<Box<dyn Fn(f32, f32, f32) -> Message>>,
|
||||
on_state_change: Option<StateChangeCallback<Message>>,
|
||||
}
|
||||
|
||||
impl<Handle, Message> Viewer<Handle, Message> {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ mod canvas;
|
|||
pub mod footer;
|
||||
pub mod header;
|
||||
mod image_viewer;
|
||||
pub mod panel_pages;
|
||||
pub mod pages_panel;
|
||||
pub mod panels;
|
||||
|
||||
use cosmic::iced::Length;
|
||||
|
|
@ -31,7 +31,7 @@ pub fn nav_bar(model: &AppModel) -> Option<Element<'_, Action<AppMessage>>> {
|
|||
return None;
|
||||
}
|
||||
|
||||
panel_pages::pages_panel(model).map(|panel| {
|
||||
pages_panel::view(model).map(|panel| {
|
||||
container(panel.map(Action::App))
|
||||
.width(Length::Shrink)
|
||||
.height(Length::Fill)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/app/view/panel_pages.rs
|
||||
// src/app/view/pages_panel.rs
|
||||
//
|
||||
// Page thumbnail panel for multi-page documents (PDF, multi-page TIFF).
|
||||
// Page navigation panel for multi-page documents (PDF, multi-page TIFF, etc.).
|
||||
|
||||
use cosmic::iced::{Alignment, Length};
|
||||
use cosmic::widget::{button, column, scrollable, text};
|
||||
|
|
@ -12,9 +12,9 @@ use crate::app::{AppMessage, AppModel};
|
|||
use crate::constant::THUMBNAIL_MAX_WIDTH;
|
||||
use crate::fl;
|
||||
|
||||
/// Content for the page navigation panel (COSMIC nav_bar).
|
||||
/// Build the page navigation panel view.
|
||||
/// Returns None if the current document doesn't support multiple pages.
|
||||
pub fn pages_panel(model: &AppModel) -> Option<Element<'static, AppMessage>> {
|
||||
pub fn view(model: &AppModel) -> Option<Element<'static, AppMessage>> {
|
||||
let doc = model.document.as_ref()?;
|
||||
|
||||
// Only show for multi-page documents.
|
||||
|
|
@ -26,7 +26,7 @@ pub fn pages_panel(model: &AppModel) -> Option<Element<'static, AppMessage>> {
|
|||
let loaded = doc.thumbnails_loaded();
|
||||
let current_page = doc.current_page()?;
|
||||
|
||||
let mut content = column::with_capacity(page_count as usize + 1)
|
||||
let mut content = column::with_capacity(page_count + 1)
|
||||
.spacing(12)
|
||||
.padding([12, 8])
|
||||
.align_x(Alignment::Center)
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/app/view/panels.rs
|
||||
//
|
||||
// Panel content for COSMIC context drawer.
|
||||
// Properties panel content for COSMIC context drawer.
|
||||
|
||||
use cosmic::iced::Length;
|
||||
use cosmic::widget::{button, column, divider, horizontal_space, icon, row, text};
|
||||
|
|
@ -10,8 +10,8 @@ use cosmic::Element;
|
|||
use crate::app::{AppMessage, AppModel};
|
||||
use crate::fl;
|
||||
|
||||
/// Content for the right-side properties panel (context drawer).
|
||||
pub fn properties_panel(model: &AppModel) -> Element<'static, AppMessage> {
|
||||
/// Build the properties panel view.
|
||||
pub fn view(model: &AppModel) -> Element<'static, AppMessage> {
|
||||
let mut content = column::with_capacity(16).spacing(8);
|
||||
|
||||
// Header with action icons
|
||||
|
|
|
|||
|
|
@ -10,19 +10,19 @@ use std::path::PathBuf;
|
|||
#[derive(Debug, Clone, CosmicConfigEntry, PartialEq)]
|
||||
#[version = 1]
|
||||
pub struct AppConfig {
|
||||
/// Optional default directory to open images from.
|
||||
/// Default directory to open when browsing for documents.
|
||||
pub default_image_dir: Option<PathBuf>,
|
||||
/// Whether the nav bar (left panel) is visible.
|
||||
/// Show page navigation panel (left sidebar for multi-page documents).
|
||||
pub nav_bar_visible: bool,
|
||||
/// Whether the context drawer (right panel) is visible.
|
||||
/// Show properties panel (right sidebar with metadata).
|
||||
pub context_drawer_visible: bool,
|
||||
/// Scale step factor for keyboard zoom (e.g., 1.1 = 10% per step).
|
||||
/// Zoom step multiplier for keyboard shortcuts (1.1 = 10% increase per step).
|
||||
pub scale_step: f32,
|
||||
/// Pan step size in pixels per key press.
|
||||
/// Pan distance in pixels per arrow key press.
|
||||
pub pan_step: f32,
|
||||
/// Minimum zoom scale (e.g., 0.1 = 10%).
|
||||
/// Minimum zoom level (0.1 = 10% of original size).
|
||||
pub min_scale: f32,
|
||||
/// Maximum zoom scale (e.g., 20.0 = 2000%).
|
||||
/// Maximum zoom level (8.0 = 800% of original size).
|
||||
pub max_scale: f32,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,19 +3,13 @@
|
|||
//
|
||||
// Application constants that should not be changed by the user.
|
||||
|
||||
/// Rotation step in degrees (90 = quarter turn).
|
||||
pub const ROTATION_STEP: i16 = 90;
|
||||
|
||||
/// Full rotation in degrees (for modulo calculation in angle normalization).
|
||||
pub const FULL_ROTATION: i16 = 360;
|
||||
|
||||
/// Minutes per degree (GPS coordinate conversion: DMS to decimal degrees).
|
||||
pub const MINUTES_PER_DEGREE: f64 = 60.0;
|
||||
|
||||
/// Seconds per degree (GPS coordinate conversion: DMS to decimal degrees).
|
||||
pub const SECONDS_PER_DEGREE: f64 = 3600.0;
|
||||
|
||||
/// Minimum pixmap size for SVG rendering (prevents 0x0 images).
|
||||
/// Minimum pixmap size for SVG rendering (prevents zero-size pixmaps).
|
||||
pub const MIN_PIXMAP_SIZE: u32 = 1;
|
||||
|
||||
/// Tolerance for scale comparisons (float precision in zoom synchronization).
|
||||
|
|
@ -24,17 +18,17 @@ pub const SCALE_EPSILON: f32 = 0.0001;
|
|||
/// Tolerance for offset comparisons (float precision in pan synchronization).
|
||||
pub const OFFSET_EPSILON: f32 = 0.01;
|
||||
|
||||
/// Maximum thumbnail width in pixels (nav bar page thumbnails).
|
||||
/// Maximum width in pixels for page navigation thumbnails.
|
||||
pub const THUMBNAIL_MAX_WIDTH: f32 = 100.0;
|
||||
|
||||
/// Thumbnail cache directory name.
|
||||
/// Cache directory name under ~/.cache/ for thumbnail storage.
|
||||
pub const CACHE_DIR: &str = "noctua";
|
||||
|
||||
/// Thumbnail file extension.
|
||||
/// File extension for cached thumbnails.
|
||||
pub const THUMBNAIL_EXT: &str = "png";
|
||||
|
||||
/// Default render scale for PDF pages.
|
||||
pub const PDF_RENDER_SCALE: f64 = 2.0;
|
||||
/// PDF page render quality multiplier (2.0 = double resolution for sharp display).
|
||||
pub const PDF_RENDER_QUALITY: f64 = 2.0;
|
||||
|
||||
/// Thumbnail render scale (smaller for quick rendering).
|
||||
pub const PDF_THUMBNAIL_SCALE: f64 = 0.25;
|
||||
/// PDF thumbnail size multiplier (0.25 = 25% for fast preview generation).
|
||||
pub const PDF_THUMBNAIL_SIZE: f64 = 0.25;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/i18n.rs
|
||||
//
|
||||
// Internationalization (i18n) support.
|
||||
|
||||
use i18n_embed::{
|
||||
DefaultLocalizer, LanguageLoader, Localizer,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/main.rs
|
||||
//
|
||||
// Application entry point.
|
||||
|
||||
mod app;
|
||||
mod config;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue