From 6a4629bb4712e60559d988cbb348284f1cffbbbb Mon Sep 17 00:00:00 2001 From: wfx Date: Wed, 4 Feb 2026 15:56:44 +0100 Subject: [PATCH] Before UI simplification - Clean Architecture complete --- MIGRATION.md | 577 ------------------------ src/application/document_manager.rs | 160 +++---- src/application/mod.rs | 1 - src/application/queries/get_document.rs | 60 --- src/application/queries/get_page.rs | 73 --- src/application/queries/mod.rs | 7 - src/domain/document/collection.rs | 22 +- src/domain/errors.rs | 142 ------ src/domain/mod.rs | 10 +- src/domain/viewport/bounds.rs | 321 ------------- src/domain/viewport/camera.rs | 236 ---------- src/domain/viewport/mod.rs | 8 - src/domain/viewport/viewport.rs | 300 ------------ 13 files changed, 69 insertions(+), 1848 deletions(-) delete mode 100644 MIGRATION.md delete mode 100644 src/application/queries/get_document.rs delete mode 100644 src/application/queries/get_page.rs delete mode 100644 src/application/queries/mod.rs delete mode 100644 src/domain/errors.rs delete mode 100644 src/domain/viewport/bounds.rs delete mode 100644 src/domain/viewport/camera.rs delete mode 100644 src/domain/viewport/mod.rs delete mode 100644 src/domain/viewport/viewport.rs diff --git a/MIGRATION.md b/MIGRATION.md deleted file mode 100644 index 5b6ce87..0000000 --- a/MIGRATION.md +++ /dev/null @@ -1,577 +0,0 @@ -# Noctua Architecture Migration - Completion Guide - -## 📊 Migration Status: 95% Complete ✅ - -Die neue Clean Architecture Struktur nach `DEVNOTE/Tree.md` ist implementiert und funktionsfähig. **Alle Compiler-Fehler wurden behoben!** Das Projekt kompiliert erfolgreich mit 0 Errors und 121 Warnings. - -**Noch offene Punkte:** -- DocumentContent implementiert noch kein Clone (model.document ist temporär None) -- Thumbnail-Generation muss neu integriert werden -- Crop-Command vollständig implementieren -- View-Layer auf DocumentManager-Zugriff umstellen - ---- - -## ✅ Abgeschlossen - -### 1. Domain Layer (100% ✓) - -``` -src/domain/ -├── document/ -│ ├── core/ # Traits, Types, Metadata -│ │ ├── document.rs # Renderable, Transformable, MultiPage traits -│ │ ├── content.rs # DocumentContent enum (type erasure) -│ │ ├── metadata.rs # BasicMeta, ExifMeta, DocumentMeta -│ │ └── page.rs # Page abstraction -│ ├── types/ # Concrete implementations -│ │ ├── raster.rs # RasterDocument -│ │ ├── vector.rs # VectorDocument -│ │ └── portable.rs # PortableDocument (PDF) -│ ├── operations/ # Document operations -│ │ ├── transform.rs # Rotate, flip, crop (high-level + low-level) -│ │ ├── render.rs # Scaling, fitting, image handles -│ │ └── export.rs # Export to various formats -│ └── collection.rs # DocumentCollection -├── viewport/ # Viewport management -│ ├── viewport.rs # Viewport state (pan, zoom, view mode) -│ ├── camera.rs # Camera controls -│ └── bounds.rs # Bounding box calculations -└── errors.rs # DomainError types -``` - -**Key Achievements:** -- ✅ Trait-basierte Abstraktion (Renderable, Transformable, MultiPage) -- ✅ Type-Erasure via DocumentContent enum -- ✅ High-Level Operations (type-agnostic transforms) -- ✅ Low-Level Operations (internal, `pub(crate)`) -- ✅ Viewport mit Camera und Bounds -- ✅ Comprehensive tests - -### 2. Infrastructure Layer (100% ✓) - -``` -src/infrastructure/ -├── loaders/ -│ ├── document_loader.rs # DocumentLoaderFactory -│ ├── raster_loader.rs -│ ├── svg_loader.rs -│ └── pdf_loader.rs -├── cache/ -│ └── thumbnail_cache.rs # Thumbnail caching -└── filesystem/ - └── file_ops.rs # File operations -``` - -**Key Achievements:** -- ✅ Factory Pattern für Document Loading -- ✅ Loader pro Dokumenttyp -- ✅ Thumbnail Cache mit Disk-Storage -- ✅ Format-Detection - -### 3. Application Layer (100% ✓) - -``` -src/application/ -├── document_manager.rs # Central document management -├── commands/ -│ ├── navigate.rs # Next/previous document -│ ├── open_document.rs -│ ├── save_document.rs -│ └── transform_document.rs # Uses high-level transform operations -├── queries/ -│ ├── get_document.rs -│ └── get_page.rs -└── services/ - ├── cache_service.rs - └── preview_service.rs -``` - -**Key Achievements:** -- ✅ DocumentManager als zentrale Orchestrierung -- ✅ Command Pattern für Operationen -- ✅ Query Pattern für Read-Only Zugriffe -- ✅ Services für Cache und Previews - -### 4. UI Layer (80% ✓) - -``` -src/ui/ -├── app/ -│ ├── app.rs # NoctuaApp (cosmic::Application) -│ ├── model.rs # AppModel -│ ├── message.rs # AppMessage -│ └── update.rs # Update logic (NEEDS WORK) -├── views/ # View components (copied, imports fixed) -│ ├── mod.rs -│ ├── canvas.rs -│ ├── header.rs -│ ├── footer.rs -│ └── panels/ -└── components/ # Reusable widgets - └── crop/ # Crop overlay (copied, imports fixed) -``` - -**Status:** -- ✅ Struktur erstellt -- ✅ Dateien verschoben -- ✅ Imports vollständig korrigiert -- ✅ `update.rs` refactored - verwendet jetzt Commands -- ✅ `app.rs` mit DocumentManager Integration -- ⚠️ Views müssen auf DocumentManager-Zugriff umgestellt werden - ---- - -## 🔧 Verbleibende Arbeiten - -### ✅ Abgeschlossen: UI Update Logic refactored - -**Status:** Vollständig implementiert! `src/ui/app/update.rs` verwendet jetzt DocumentManager und Commands. - -**Implementierte Messages:** -- ✅ `OpenPath` - Verwendet `document_manager.open_document()` -- ✅ `NextDocument` - Verwendet `document_manager.next_document()` -- ✅ `PrevDocument` - Verwendet `document_manager.previous_document()` -- ✅ `RotateCW/CCW` - Verwendet `TransformDocumentCommand` -- ✅ `FlipHorizontal/Vertical` - Verwendet `TransformDocumentCommand` -- ⚠️ `ApplyCrop` - Temporär deaktiviert (needs CropDocumentCommand) -- ⚠️ `SaveAs` - Temporär deaktiviert (needs file dialog) - -#### ✅ Schritt 1: DocumentManager zu NoctuaApp hinzugefügt - -```rust -// In src/ui/app/app.rs - IMPLEMENTIERT -use crate::application::DocumentManager; - -pub struct NoctuaApp { - core: Core, - pub model: AppModel, - nav: nav_bar::Model, - context_page: ContextPage, - pub config: AppConfig, - config_handler: Option, - - // ✅ DocumentManager integriert - pub document_manager: DocumentManager, -} - -impl cosmic::Application for NoctuaApp { - fn init(mut core: Core, flags: Self::Flags) -> (Self, Task>) { - // ... - let document_manager = DocumentManager::new(); - - // Initial document öffnen (falls vorhanden) - let init_task = if let Some(path) = initial_path { - let mut manager = document_manager.clone(); - Task::perform( - async move { - manager.open_document(&path).ok(); - () - }, - |_| Action::App(AppMessage::RefreshView) - ) - } else { - Task::none() - }; - - let app = Self { - // ... - document_manager, - }; - - (app, init_task) - } -} -``` - -#### ✅ Schritt 2: Update-Funktionen umgeschrieben - -**Implementierungsstatus:** Vollständig refactored! - -```rust -// In src/ui/app/update.rs - IMPLEMENTIERT - -pub fn update(app: &mut NoctuaApp, msg: &AppMessage) -> UpdateResult { - match message { - // Navigation - AppMessage::NextDocument => { - if let Some(path) = self.document_manager.next_document() { - self.sync_model_from_manager(); - self.model.reset_pan(); - self.model.view_mode = ViewMode::Fit; - } - } - - AppMessage::PrevDocument => { - if let Some(path) = self.document_manager.previous_document() { - self.sync_model_from_manager(); - self.model.reset_pan(); - self.model.view_mode = ViewMode::Fit; - } - } - - // Transformationen - AppMessage::RotateCW => { - use crate::application::commands::transform_document::{ - TransformDocumentCommand, TransformOperation - }; - - let cmd = TransformDocumentCommand::new(TransformOperation::RotateCw); - if let Err(e) = cmd.execute(&mut self.document_manager) { - self.model.set_error(format!("Rotation failed: {}", e)); - } else { - self.sync_model_from_manager(); - } - } - - AppMessage::FlipHorizontal => { - use crate::application::commands::transform_document::{ - TransformDocumentCommand, TransformOperation - }; - - let cmd = TransformDocumentCommand::new(TransformOperation::FlipHorizontal); - if let Err(e) = cmd.execute(&mut self.document_manager) { - self.model.set_error(format!("Flip failed: {}", e)); - } else { - self.sync_model_from_manager(); - } - } - - // ... weitere Messages - } - - Task::none() - } - - // Helper: Sync AppModel from DocumentManager - fn sync_model_from_manager(&mut self) { - if let Some(doc) = self.document_manager.current_document() { - self.model.document = Some(doc.clone()); - self.model.current_dimensions = doc.dimensions(); - self.model.metadata = self.document_manager.current_metadata().cloned(); - self.model.current_path = self.document_manager.current_path().map(|p| p.to_path_buf()); - } else { - self.model.document = None; - self.model.current_dimensions = (0, 0); - self.model.metadata = None; - self.model.current_path = None; - } - } -} -``` - -### Priorität 2: Fehlende Funktionen implementieren (Teilweise) - -#### 2.1 Crop-Funktion - -```rust -// In src/application/commands/crop_document.rs (NEU erstellen) - -use crate::domain::document::operations::transform::crop_image; - -pub struct CropDocumentCommand { - pub x: u32, - pub y: u32, - pub width: u32, - pub height: u32, -} - -impl CropDocumentCommand { - pub fn execute(&self, manager: &mut DocumentManager) -> DocResult<()> { - let document = manager.current_document_mut() - .ok_or_else(|| anyhow::anyhow!("No document loaded"))?; - - // Get underlying image (nur für RasterDocument) - match document { - DocumentContent::Raster(ref mut raster) => { - let img = raster.image(); - let cropped = crop_image(img, self.x, self.y, self.width, self.height) - .ok_or_else(|| anyhow::anyhow!("Invalid crop region"))?; - - // Create new RasterDocument from cropped image - // TODO: Implement replacement logic - } - _ => { - return Err(anyhow::anyhow!("Crop only supported for raster images")); - } - } - - Ok(()) - } -} -``` - -#### 2.2 Save-As-Funktion - -```rust -// In src/application/commands/save_document.rs (bereits vorhanden, erweitern) - -impl SaveDocumentCommand { - pub fn execute(&self, manager: &DocumentManager, path: &Path) -> DocResult<()> { - let document = manager.current_document() - .ok_or_else(|| anyhow::anyhow!("No document loaded"))?; - - let format = self.format - .or_else(|| ExportFormat::from_path(path)) - .ok_or_else(|| anyhow::anyhow!("Could not determine export format"))?; - - // Get rendered image - match document { - DocumentContent::Raster(raster) => { - let img = raster.image(); - export_image(img, path, format, &ImageExportOptions::default())?; - } - DocumentContent::Vector(vector) => { - // TODO: Implement vector export - return Err(anyhow::anyhow!("Vector export not yet implemented")); - } - DocumentContent::Portable(portable) => { - // TODO: Implement PDF export - return Err(anyhow::anyhow!("PDF export not yet implemented")); - } - } - - Ok(()) - } -} -``` - -### Priorität 3: View-Dateien anpassen - -Die meisten Views sollten funktionieren, aber einige müssen möglicherweise angepasst werden: - -```bash -# Überprüfe verbleibende Fehler in Views -cargo check 2>&1 | grep "src/ui/views" - -# Typische Fixes: -# - `crate::app::document::*` → `crate::domain::document::*` -# - `crate::app::model::*` → `crate::ui::app::model::*` -# - `super::super::*` → `crate::ui::*` oder `crate::domain::*` -``` - ---- - -## 🎯 Architektur-Entscheidungen - -### 1. Zwei-Ebenen Transformationen - -**High-Level (Public API):** -```rust -// Type-agnostic, funktioniert mit allen Dokumenttypen -use crate::domain::document::operations::transform; - -transform::rotate_document_cw(&mut document)?; -transform::flip_document_horizontal(&mut document)?; -``` - -**Low-Level (Internal):** -```rust -// pub(crate) - nur in Document-Type-Implementierungen -fn rotate(&mut self, rotation: Rotation) { - self.image = apply_rotation(self.image, rotation); -} -``` - -**Regel:** Verwende IMMER High-Level Operationen in Application/UI Code! - -### 2. DocumentManager als Single Source of Truth - -```rust -// ❌ NICHT: Direkter Zugriff auf model.document -if let Some(doc) = &mut model.document { - doc.rotate_cw(); -} - -// ✅ JA: Über DocumentManager -let cmd = TransformDocumentCommand::new(TransformOperation::RotateCw); -cmd.execute(&mut self.document_manager)?; -self.sync_model_from_manager(); -``` - -### 3. Commands für alle Operationen - -```rust -// Jede Operation sollte ein Command haben -use crate::application::commands::*; - -// Navigation -NavigateCommand::new(NavigationDirection::Next).execute(&mut manager)?; - -// Transformationen -TransformDocumentCommand::new(TransformOperation::RotateCw).execute(&mut manager)?; - -// Öffnen -OpenDocumentCommand::new().execute(&mut manager, &path)?; -``` - ---- - -## 🔍 Debugging-Hilfe - -### Compiler-Fehler beheben - -```bash -# Alle Fehler anzeigen -cargo check 2>&1 | less - -# Nur Import-Fehler -cargo check 2>&1 | grep "unresolved import" - -# Fehler nach Datei gruppiert -cargo check 2>&1 | grep "^ -->" | sort | uniq -c -``` - -### Typische Fehlerquellen - -1. **`unresolved import crate::app::`** - - Fix: `crate::app::` → `crate::ui::app::` oder `crate::domain::` - -2. **`could not find utils in super`** - - Fix: `super::utils::` → `crate::domain::document::operations::transform::` - -3. **`no document in ui::app`** - - Fix: `super::document` → `crate::domain::document` - -4. **`AppModel not in scope in update.rs`** - - Fix: Add `use super::model::AppModel;` - ---- - -## 📝 Testing - -Nach dem Refactoring: - -```bash -# Build -cargo build --release - -# Run -cargo run -- /path/to/image.png - -# Tests -cargo test - -# Clippy -cargo clippy -- -W clippy::pedantic -``` - ---- - -## 🎉 Nach Abschluss - -Die neue Architektur bietet: - -1. **Klare Separation of Concerns** - - Domain = Geschäftslogik - - Application = Use Cases - - Infrastructure = Externe Dependencies - - UI = COSMIC Interface - -2. **Testbarkeit** - - Domain ohne UI testbar - - Commands isoliert testbar - - Loaders austauschbar - -3. **Erweiterbarkeit** - - Neue Dokumenttypen (DJVU, EPUB) einfach hinzufügbar - - Neue Operationen folgen klarem Pattern - - Plugin-System möglich - -4. **Wartbarkeit** - - Single Responsibility per Modul - - Type-safe Abstractions - - Future-proof für IrfanView-Features - ---- - -## 📚 Referenzen - -- **Tree.md** - Ziel-Architektur -- **AGENTS.md** - Wird nach Abschluss aktualisiert -- **operations/README.md** - Dokumentation der Transform-Operations -- **Clean Architecture** - Uncle Bob Martin -- **Domain-Driven Design** - Eric Evans - ---- - -## ✅ Checkliste - -- [x] Domain Layer vollständig implementiert -- [x] Infrastructure Layer vollständig implementiert -- [x] Application Layer vollständig implementiert -- [x] UI Struktur erstellt und Dateien verschoben -- [x] High-Level/Low-Level Transform Operations getrennt -- [x] DocumentManager in NoctuaApp integrieren ✅ -- [x] update.rs refactoren (alle Messages) ✅ -- [x] Alle Compiler-Fehler beheben (0 errors!) ✅ -- [ ] DocumentContent Clone implementieren -- [ ] Crop-Command vollständig implementieren -- [ ] Save-As mit File-Dialog erweitern -- [ ] Thumbnail-Generation neu integrieren -- [ ] Tests aktualisieren -- [ ] AGENTS.md aktualisieren -- [ ] Smoke-Test durchführen - -**Geschätzte Zeit bis Completion:** 2-3 Stunden focused work - ---- - -## 🎊 Erfolge dieser Session - -### Implementierte Änderungen - -1. **DocumentManager Integration** ✅ - - `NoctuaApp` enthält jetzt `document_manager: DocumentManager` - - Initial document loading beim App-Start - - `sync_model_from_manager()` Helper-Funktion - -2. **Update Logic Refactoring** ✅ - - Alle Navigation-Messages verwenden DocumentManager - - Alle Transform-Messages verwenden `TransformDocumentCommand` - - Borrowing-Probleme durch direkte `app.model` Zugriffe gelöst - -3. **Trait-Implementierungen korrigiert** ✅ - - `MultiPageThumbnails` trait signatures angepasst - - `thumbnails_loaded()` gibt jetzt `bool` zurück - - `generate_thumbnail_page()` gibt `DocResult<()>` zurück - - `GenericImageView` trait imports hinzugefügt - -4. **Import-Struktur bereinigt** ✅ - - DragHandle-Duplikate konsolidiert (components vs views) - - CropSelection verwendet jetzt components-Version - - Renderable trait richtig in Scope gebracht - -5. **File Operations umstrukturiert** ✅ - - Alte AppModel-abhängige Funktionen deprecated - - DocumentManager übernimmt File-Loading - - Navigation über DocumentManager-Methoden - -### Bekannte Limitierungen - -**DocumentContent Clone:** -- `DocumentContent` implementiert noch kein `Clone` -- Grund: `PortableDocument` enthält nicht-cloneable `PopplerDocument` -- Workaround: `model.document` ist temporär `None` -- Langfristig: Model sollte nur Metadaten halten, nicht Document selbst - -**Thumbnail-Generation:** -- Temporär deaktiviert wegen fehlendem document in model -- Muss über DocumentManager neu implementiert werden -- `get_thumbnail()` benötigt `&mut self`, aber Views haben `&self` - -**Crop Operation:** -- Command-Struktur vorhanden, aber Implementierung incomplete -- Benötigt coordinate transformation und image manipulation -- UI zeigt Placeholder-Fehler - -### Kompilierungsstatus - -``` -✅ 0 Errors -⚠️ 121 Warnings (mostly unused code and imports) -``` - -**Geschätzte Zeit bis Completion:** 2-3 Stunden für verbleibende Features - -Viel Erfolg! 🚀 \ No newline at end of file diff --git a/src/application/document_manager.rs b/src/application/document_manager.rs index 5b27e2b..29f9f8b 100644 --- a/src/application/document_manager.rs +++ b/src/application/document_manager.rs @@ -5,8 +5,9 @@ use std::path::{Path, PathBuf}; +use crate::domain::document::collection::DocumentCollection; use crate::domain::document::core::content::DocumentContent; -use crate::domain::document::core::document::{DocResult, Renderable}; +use crate::domain::document::core::document::DocResult; use crate::domain::document::core::metadata::DocumentMeta; use crate::infrastructure::filesystem::file_ops; use crate::infrastructure::loaders::DocumentLoaderFactory; @@ -14,17 +15,12 @@ use crate::infrastructure::loaders::DocumentLoaderFactory; /// Central document manager. /// /// Orchestrates document loading, metadata extraction, and folder navigation. +/// Uses DocumentCollection (Domain Layer) for navigation logic. pub struct DocumentManager { - /// Current document (if any). - current_document: Option, - /// Current document path. - current_path: Option, + /// Document collection for navigation (Domain Layer abstraction). + collection: DocumentCollection, /// Current document metadata. current_metadata: Option, - /// Folder entries for navigation. - folder_entries: Vec, - /// Current index in folder entries. - current_index: Option, /// Document loader factory. loader: DocumentLoaderFactory, } @@ -34,11 +30,8 @@ impl DocumentManager { #[must_use] pub fn new() -> Self { Self { - current_document: None, - current_path: None, + collection: DocumentCollection::new(), current_metadata: None, - folder_entries: Vec::new(), - current_index: None, loader: DocumentLoaderFactory::new(), } } @@ -51,10 +44,11 @@ impl DocumentManager { // Determine the actual file to open let file_path = if path.is_dir() { // Scan directory and find first supported file - self.scan_folder(path); + let paths = file_ops::collect_supported_files(path); + self.collection = DocumentCollection::from_paths(paths); - self.folder_entries - .first() + self.collection + .current_path() .ok_or_else(|| anyhow::anyhow!("No supported files found in directory"))? .clone() } else { @@ -70,13 +64,15 @@ impl DocumentManager { // Scan folder for navigation if not already done if !path.is_dir() { if let Some(parent) = file_path.parent() { - self.scan_folder(parent); + let paths = file_ops::collect_supported_files(parent); + self.collection = DocumentCollection::from_paths(paths); + // Find and set current document index + if let Some(idx) = self.collection.paths().iter().position(|p| p == &file_path) { + self.collection.goto(idx); + } } } - // Find current document index - self.current_index = self.folder_entries.iter().position(|p| p == &file_path); - // Generate thumbnails for multi-page documents (PDF) let mut document = document; if document.is_multi_page() { @@ -86,8 +82,8 @@ impl DocumentManager { } } - self.current_document = Some(document); - self.current_path = Some(file_path); + // Store document in collection + self.collection.set_current_document(document); self.current_metadata = Some(metadata); Ok(()) @@ -96,26 +92,28 @@ impl DocumentManager { /// Get the current document. #[must_use] pub fn current_document(&self) -> Option<&DocumentContent> { - self.current_document.as_ref() + self.collection.current_document() } /// Get a mutable reference to the current document. #[must_use] pub fn current_document_mut(&mut self) -> Option<&mut DocumentContent> { - self.current_document.as_mut() + self.collection.current_document_mut() } /// Get thumbnail handle for a specific page (read-only access). /// Returns None if the thumbnail hasn't been generated yet. #[must_use] pub fn get_thumbnail_handle(&self, page: usize) -> Option { - self.current_document.as_ref()?.get_thumbnail_handle(page) + self.collection + .current_document()? + .get_thumbnail_handle(page) } /// Get the current document path. #[must_use] pub fn current_path(&self) -> Option<&Path> { - self.current_path.as_deref() + self.collection.current_path().map(|p| p.as_path()) } /// Get the current document metadata. @@ -124,38 +122,33 @@ impl DocumentManager { self.current_metadata.as_ref() } - /// Get folder entries for navigation. + /// Get all folder entries for navigation. #[must_use] pub fn folder_entries(&self) -> &[PathBuf] { - &self.folder_entries + self.collection.paths() } /// Get current index in folder. #[must_use] pub fn current_index(&self) -> Option { - self.current_index + self.collection.current_index() } /// Navigate to the next document in the folder. /// /// Wraps around to the first document when at the end. pub fn next_document(&mut self) -> Option { - if self.folder_entries.is_empty() { + // Use DocumentCollection navigation + if self.collection.has_next() { + self.collection.next(); + } else if !self.collection.is_empty() { + // Wrap around to first + self.collection.goto(0); + } else { return None; } - let new_index = match self.current_index { - Some(idx) => { - if idx + 1 < self.folder_entries.len() { - idx + 1 - } else { - 0 // Wrap around to first - } - } - None => 0, - }; - - let next_path = self.folder_entries.get(new_index)?.clone(); + let next_path = self.collection.current_path()?.clone(); if self.open_document(&next_path).is_ok() { Some(next_path) } else { @@ -167,22 +160,18 @@ impl DocumentManager { /// /// Wraps around to the last document when at the beginning. pub fn previous_document(&mut self) -> Option { - if self.folder_entries.is_empty() { + // Use DocumentCollection navigation + if self.collection.has_previous() { + self.collection.previous(); + } else if !self.collection.is_empty() { + // Wrap around to last + let last_idx = self.collection.len() - 1; + self.collection.goto(last_idx); + } else { return None; } - let new_index = match self.current_index { - Some(idx) => { - if idx > 0 { - idx - 1 - } else { - self.folder_entries.len() - 1 // Wrap around to last - } - } - None => self.folder_entries.len().saturating_sub(1), - }; - - let prev_path = self.folder_entries.get(new_index)?.clone(); + let prev_path = self.collection.current_path()?.clone(); if self.open_document(&prev_path).is_ok() { Some(prev_path) } else { @@ -193,77 +182,30 @@ impl DocumentManager { /// Close the current document. #[allow(dead_code)] pub fn close_document(&mut self) { - self.current_document = None; - self.current_path = None; + self.collection.clear_current_document(); self.current_metadata = None; } - /// Scan a folder for supported documents. - fn scan_folder(&mut self, folder: &Path) { - self.folder_entries = file_ops::collect_supported_files(folder); - } - /// Extract metadata from a document. fn extract_metadata(&self, path: &Path, document: &DocumentContent) -> DocumentMeta { - use crate::domain::document::core::metadata::{BasicMeta, DocumentMeta, ExifMeta}; - - let info = document.info(); - let (width, height) = document.dimensions(); - - let file_name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string(); - - let file_path = path.to_string_lossy().to_string(); - - let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); - - let format = info.format; - let color_type = format!("{}", document.kind()); - - let basic = BasicMeta { - file_name, - file_path, - format, - width, - height, - file_size, - color_type, - }; - - // Extract EXIF data for raster images (JPEG, TIFF) - let exif = - if document.kind() == crate::domain::document::core::content::DocumentKind::Raster { - file_ops::read_file_bytes(path).and_then(|bytes| ExifMeta::from_bytes(&bytes)) - } else { - None - }; - - DocumentMeta { basic, exif } + // Use the document's own extract_meta() method + // This properly delegates to the type-specific implementation + // (RasterDocument, VectorDocument, or PortableDocument) + document.extract_meta(path) } /// Check if there is a next document available. #[must_use] #[allow(dead_code)] pub fn has_next(&self) -> bool { - if let Some(current) = self.current_index { - current + 1 < self.folder_entries.len() - } else { - false - } + self.collection.has_next() } /// Check if there is a previous document available. #[must_use] #[allow(dead_code)] pub fn has_previous(&self) -> bool { - if let Some(current) = self.current_index { - current > 0 - } else { - false - } + self.collection.has_previous() } } diff --git a/src/application/mod.rs b/src/application/mod.rs index 7b65e0b..00e19f2 100644 --- a/src/application/mod.rs +++ b/src/application/mod.rs @@ -5,7 +5,6 @@ pub mod commands; pub mod document_manager; -pub mod queries; pub mod services; // Re-export document manager diff --git a/src/application/queries/get_document.rs b/src/application/queries/get_document.rs deleted file mode 100644 index b8836ff..0000000 --- a/src/application/queries/get_document.rs +++ /dev/null @@ -1,60 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/application/queries/get_document.rs -// -// Get document query: retrieve current document information. -// Reserved for future CQRS pattern - currently using direct DocumentManager methods. - -#![allow(dead_code)] - -use crate::application::document_manager::DocumentManager; -use crate::domain::document::core::metadata::DocumentMeta; - -/// Get document query result. -#[derive(Debug)] -pub struct DocumentInfo { - /// Document content reference. - pub has_document: bool, - /// Document metadata. - pub metadata: Option, - /// Current page (for multi-page documents). - pub current_page: usize, - /// Total pages (for multi-page documents). - pub total_pages: usize, -} - -/// Get document query. -pub struct GetDocumentQuery; - -impl GetDocumentQuery { - /// Create a new get document query. - #[must_use] - pub fn new() -> Self { - Self - } - - /// Execute the query and return document information. - #[must_use] - pub fn execute(&self, manager: &DocumentManager) -> DocumentInfo { - let has_document = manager.current_document().is_some(); - let metadata = manager.current_metadata().cloned(); - - let (current_page, total_pages) = if let Some(doc) = manager.current_document() { - (doc.current_page(), doc.page_count()) - } else { - (0, 0) - }; - - DocumentInfo { - has_document, - metadata, - current_page, - total_pages, - } - } -} - -impl Default for GetDocumentQuery { - fn default() -> Self { - Self::new() - } -} diff --git a/src/application/queries/get_page.rs b/src/application/queries/get_page.rs deleted file mode 100644 index 04041c0..0000000 --- a/src/application/queries/get_page.rs +++ /dev/null @@ -1,73 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/application/queries/get_page.rs -// -// Get page query: retrieve page information from multi-page documents. -// Reserved for future CQRS pattern - currently using direct DocumentManager methods. - -#![allow(dead_code)] - -use cosmic::widget::image::Handle as ImageHandle; - -use crate::application::document_manager::DocumentManager; -use crate::domain::document::core::document::{DocResult, Renderable}; - -/// Page information result. -#[derive(Debug, Clone)] -pub struct PageInfo { - /// Page index (0-based). - pub index: usize, - /// Page width in pixels. - pub width: u32, - /// Page height in pixels. - pub height: u32, - /// Page thumbnail (if available). - pub thumbnail: Option, -} - -/// Get page query. -pub struct GetPageQuery { - /// Page index to retrieve. - page_index: usize, -} - -impl GetPageQuery { - /// Create a new get page query. - #[must_use] - pub fn new(page_index: usize) -> Self { - Self { page_index } - } - - /// Execute the query and return page information. - pub fn execute(&self, manager: &DocumentManager) -> DocResult> { - let document = match manager.current_document() { - Some(doc) => doc, - None => return Ok(None), - }; - - // Check if page index is valid - if self.page_index >= document.page_count() { - return Err(anyhow::anyhow!( - "Invalid page index {} (document has {} pages)", - self.page_index, - document.page_count() - )); - } - - // For now, return basic info - // TODO: Implement proper page dimension retrieval - let info = document.info(); - - Ok(Some(PageInfo { - index: self.page_index, - width: info.width, - height: info.height, - thumbnail: None, // TODO: Retrieve thumbnail from cache - })) - } - - /// Get the page index being queried. - #[must_use] - pub fn page_index(&self) -> usize { - self.page_index - } -} diff --git a/src/application/queries/mod.rs b/src/application/queries/mod.rs deleted file mode 100644 index d347ada..0000000 --- a/src/application/queries/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/application/queries/mod.rs -// -// Application queries: read-only operations on documents. - -pub mod get_document; -pub mod get_page; diff --git a/src/domain/document/collection.rs b/src/domain/document/collection.rs index 41850e3..43c4c09 100644 --- a/src/domain/document/collection.rs +++ b/src/domain/document/collection.rs @@ -103,11 +103,12 @@ impl DocumentCollection { /// Returns the new index if successful, None if already at the end. pub fn next(&mut self) -> Option { if let Some(current) = self.current_index - && current + 1 < self.paths.len() { - self.current_index = Some(current + 1); - self.current_document = None; // Clear document (needs reload) - return self.current_index; - } + && current + 1 < self.paths.len() + { + self.current_index = Some(current + 1); + self.current_document = None; // Clear document (needs reload) + return self.current_index; + } None } @@ -116,11 +117,12 @@ impl DocumentCollection { /// Returns the new index if successful, None if already at the start. pub fn previous(&mut self) -> Option { if let Some(current) = self.current_index - && current > 0 { - self.current_index = Some(current - 1); - self.current_document = None; // Clear document (needs reload) - return self.current_index; - } + && current > 0 + { + self.current_index = Some(current - 1); + self.current_document = None; // Clear document (needs reload) + return self.current_index; + } None } diff --git a/src/domain/errors.rs b/src/domain/errors.rs deleted file mode 100644 index 29681cc..0000000 --- a/src/domain/errors.rs +++ /dev/null @@ -1,142 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/domain/errors.rs -// -// Domain-specific error types. - -use std::fmt; -use std::io; -use std::path::PathBuf; - -/// Domain-specific errors. -#[derive(Debug)] -pub enum DomainError { - /// Document loading failed. - DocumentLoad { - path: PathBuf, - reason: String, - }, - /// Unsupported document format. - UnsupportedFormat { - path: PathBuf, - extension: Option, - }, - /// Document rendering failed. - RenderFailed { - reason: String, - }, - /// Page navigation error (invalid page index). - InvalidPage { - requested: usize, - total: usize, - }, - /// Transformation operation failed. - TransformFailed { - operation: String, - reason: String, - }, - /// Export operation failed. - ExportFailed { - path: PathBuf, - reason: String, - }, - /// I/O error. - Io { - path: Option, - error: io::Error, - }, - /// Invalid dimensions. - InvalidDimensions { - width: u32, - height: u32, - }, - /// Viewport error. - Viewport { - reason: String, - }, - /// Generic error with message. - Other { - message: String, - }, -} - -impl fmt::Display for DomainError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::DocumentLoad { path, reason } => { - write!(f, "Failed to load document '{}': {}", path.display(), reason) - } - Self::UnsupportedFormat { path, extension } => { - if let Some(ext) = extension { - write!( - f, - "Unsupported format '.{}' for file '{}'", - ext, - path.display() - ) - } else { - write!(f, "Unsupported format for file '{}'", path.display()) - } - } - Self::RenderFailed { reason } => { - write!(f, "Rendering failed: {reason}") - } - Self::InvalidPage { requested, total } => { - write!( - f, - "Invalid page index {requested} (document has {total} pages)" - ) - } - Self::TransformFailed { operation, reason } => { - write!(f, "Transformation '{operation}' failed: {reason}") - } - Self::ExportFailed { path, reason } => { - write!(f, "Export to '{}' failed: {}", path.display(), reason) - } - Self::Io { path, error } => { - if let Some(p) = path { - write!(f, "I/O error for '{}': {}", p.display(), error) - } else { - write!(f, "I/O error: {error}") - } - } - Self::InvalidDimensions { width, height } => { - write!(f, "Invalid dimensions: {width}x{height}") - } - Self::Viewport { reason } => { - write!(f, "Viewport error: {reason}") - } - Self::Other { message } => { - write!(f, "{message}") - } - } - } -} - -impl std::error::Error for DomainError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Io { error, .. } => Some(error), - _ => None, - } - } -} - -impl From for DomainError { - fn from(error: io::Error) -> Self { - Self::Io { path: None, error } - } -} - -impl From for DomainError { - fn from(message: String) -> Self { - Self::Other { message } - } -} - -impl From<&str> for DomainError { - fn from(message: &str) -> Self { - Self::Other { - message: message.to_string(), - } - } -} diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 8fa185a..c4ad94c 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -4,8 +4,6 @@ // Domain layer: business logic, document abstractions, and viewport management. pub mod document; -pub mod errors; -pub mod viewport; // Re-export core document types #[allow(unused_imports)] @@ -13,6 +11,10 @@ pub use document::core::content::DocumentContent; #[allow(unused_imports)] pub use document::core::metadata::DocumentMeta; -// Note: Low-level pixel operations (apply_rotation, apply_flip, crop_image) +// Note: Viewport and error handling were removed to reduce code bloat. +// - Viewport: Was 865 lines of unused code (planned feature) +// - Domain Errors: Not integrated, anyhow::Result is sufficient +// +// Low-level pixel operations (apply_rotation, apply_flip, crop_image) // are internal helpers used only by document type implementations. -// Use high-level operations above for all application and UI code. +// Use high-level operations for all application and UI code. diff --git a/src/domain/viewport/bounds.rs b/src/domain/viewport/bounds.rs deleted file mode 100644 index 7ede29f..0000000 --- a/src/domain/viewport/bounds.rs +++ /dev/null @@ -1,321 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/domain/viewport/bounds.rs -// -// Bounding box calculations and intersection tests for viewport. - -/// A rectangular bounding box. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Bounds { - /// X coordinate of top-left corner. - pub x: f32, - /// Y coordinate of top-left corner. - pub y: f32, - /// Width of the bounds. - pub width: f32, - /// Height of the bounds. - pub height: f32, -} - -impl Bounds { - /// Create a new bounds rectangle. - #[must_use] - pub fn new(x: f32, y: f32, width: f32, height: f32) -> Self { - Self { - x, - y, - width, - height, - } - } - - /// Create bounds from two points (top-left and bottom-right). - #[must_use] - pub fn from_corners(x1: f32, y1: f32, x2: f32, y2: f32) -> Self { - let x = x1.min(x2); - let y = y1.min(y2); - let width = (x2 - x1).abs(); - let height = (y2 - y1).abs(); - - Self { - x, - y, - width, - height, - } - } - - /// Create bounds centered at a point. - #[must_use] - pub fn centered(center_x: f32, center_y: f32, width: f32, height: f32) -> Self { - Self { - x: center_x - width / 2.0, - y: center_y - height / 2.0, - width, - height, - } - } - - /// Get the right edge coordinate. - #[must_use] - pub fn right(&self) -> f32 { - self.x + self.width - } - - /// Get the bottom edge coordinate. - #[must_use] - pub fn bottom(&self) -> f32 { - self.y + self.height - } - - /// Get the center point. - #[must_use] - pub fn center(&self) -> (f32, f32) { - (self.x + self.width / 2.0, self.y + self.height / 2.0) - } - - /// Get the top-left corner. - #[must_use] - pub fn top_left(&self) -> (f32, f32) { - (self.x, self.y) - } - - /// Get the top-right corner. - #[must_use] - pub fn top_right(&self) -> (f32, f32) { - (self.right(), self.y) - } - - /// Get the bottom-left corner. - #[must_use] - pub fn bottom_left(&self) -> (f32, f32) { - (self.x, self.bottom()) - } - - /// Get the bottom-right corner. - #[must_use] - pub fn bottom_right(&self) -> (f32, f32) { - (self.right(), self.bottom()) - } - - /// Check if a point is inside this bounds. - #[must_use] - pub fn contains_point(&self, x: f32, y: f32) -> bool { - x >= self.x && x <= self.right() && y >= self.y && y <= self.bottom() - } - - /// Check if this bounds fully contains another bounds. - #[must_use] - pub fn contains_bounds(&self, other: &Self) -> bool { - other.x >= self.x - && other.y >= self.y - && other.right() <= self.right() - && other.bottom() <= self.bottom() - } - - /// Check if this bounds intersects with another bounds. - #[must_use] - pub fn intersects(&self, other: &Self) -> bool { - self.x < other.right() - && self.right() > other.x - && self.y < other.bottom() - && self.bottom() > other.y - } - - /// Calculate the intersection of two bounds. - /// - /// Returns None if the bounds don't intersect. - #[must_use] - pub fn intersection(&self, other: &Self) -> Option { - if !self.intersects(other) { - return None; - } - - let x = self.x.max(other.x); - let y = self.y.max(other.y); - let right = self.right().min(other.right()); - let bottom = self.bottom().min(other.bottom()); - - Some(Self::new(x, y, right - x, bottom - y)) - } - - /// Calculate the union of two bounds (bounding box containing both). - #[must_use] - pub fn union(&self, other: &Self) -> Self { - let x = self.x.min(other.x); - let y = self.y.min(other.y); - let right = self.right().max(other.right()); - let bottom = self.bottom().max(other.bottom()); - - Self::new(x, y, right - x, bottom - y) - } - - /// Expand the bounds by a margin on all sides. - #[must_use] - pub fn expand(&self, margin: f32) -> Self { - Self::new( - self.x - margin, - self.y - margin, - self.width + 2.0 * margin, - self.height + 2.0 * margin, - ) - } - - /// Shrink the bounds by a margin on all sides. - /// - /// Returns None if the bounds would become invalid. - #[must_use] - pub fn shrink(&self, margin: f32) -> Option { - let new_width = self.width - 2.0 * margin; - let new_height = self.height - 2.0 * margin; - - if new_width <= 0.0 || new_height <= 0.0 { - return None; - } - - Some(Self::new( - self.x + margin, - self.y + margin, - new_width, - new_height, - )) - } - - /// Scale the bounds by a factor from center. - #[must_use] - pub fn scale(&self, factor: f32) -> Self { - let (center_x, center_y) = self.center(); - let new_width = self.width * factor; - let new_height = self.height * factor; - - Self::centered(center_x, center_y, new_width, new_height) - } - - /// Translate the bounds by an offset. - #[must_use] - pub fn translate(&self, dx: f32, dy: f32) -> Self { - Self::new(self.x + dx, self.y + dy, self.width, self.height) - } - - /// Get the area of the bounds. - #[must_use] - pub fn area(&self) -> f32 { - self.width * self.height - } - - /// Check if the bounds is empty (zero or negative area). - #[must_use] - pub fn is_empty(&self) -> bool { - self.width <= 0.0 || self.height <= 0.0 - } - - /// Clamp this bounds to fit within another bounds. - #[must_use] - pub fn clamp_to(&self, container: &Self) -> Self { - let x = self.x.max(container.x).min(container.right() - self.width); - let y = self.y.max(container.y).min(container.bottom() - self.height); - - Self::new(x, y, self.width, self.height) - } -} - -impl Default for Bounds { - fn default() -> Self { - Self::new(0.0, 0.0, 0.0, 0.0) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_bounds_creation() { - let bounds = Bounds::new(10.0, 20.0, 100.0, 200.0); - assert_eq!(bounds.x, 10.0); - assert_eq!(bounds.y, 20.0); - assert_eq!(bounds.width, 100.0); - assert_eq!(bounds.height, 200.0); - } - - #[test] - fn test_bounds_from_corners() { - let bounds = Bounds::from_corners(10.0, 20.0, 110.0, 220.0); - assert_eq!(bounds.x, 10.0); - assert_eq!(bounds.y, 20.0); - assert_eq!(bounds.width, 100.0); - assert_eq!(bounds.height, 200.0); - } - - #[test] - fn test_bounds_edges() { - let bounds = Bounds::new(10.0, 20.0, 100.0, 200.0); - assert_eq!(bounds.right(), 110.0); - assert_eq!(bounds.bottom(), 220.0); - } - - #[test] - fn test_contains_point() { - let bounds = Bounds::new(0.0, 0.0, 100.0, 100.0); - assert!(bounds.contains_point(50.0, 50.0)); - assert!(bounds.contains_point(0.0, 0.0)); - assert!(bounds.contains_point(100.0, 100.0)); - assert!(!bounds.contains_point(-1.0, 50.0)); - assert!(!bounds.contains_point(50.0, 101.0)); - } - - #[test] - fn test_intersection() { - let a = Bounds::new(0.0, 0.0, 100.0, 100.0); - let b = Bounds::new(50.0, 50.0, 100.0, 100.0); - - let intersection = a.intersection(&b).unwrap(); - assert_eq!(intersection.x, 50.0); - assert_eq!(intersection.y, 50.0); - assert_eq!(intersection.width, 50.0); - assert_eq!(intersection.height, 50.0); - } - - #[test] - fn test_no_intersection() { - let a = Bounds::new(0.0, 0.0, 100.0, 100.0); - let b = Bounds::new(200.0, 200.0, 100.0, 100.0); - - assert!(!a.intersects(&b)); - assert!(a.intersection(&b).is_none()); - } - - #[test] - fn test_union() { - let a = Bounds::new(0.0, 0.0, 100.0, 100.0); - let b = Bounds::new(50.0, 50.0, 100.0, 100.0); - - let union = a.union(&b); - assert_eq!(union.x, 0.0); - assert_eq!(union.y, 0.0); - assert_eq!(union.width, 150.0); - assert_eq!(union.height, 150.0); - } - - #[test] - fn test_expand_shrink() { - let bounds = Bounds::new(10.0, 10.0, 100.0, 100.0); - - let expanded = bounds.expand(10.0); - assert_eq!(expanded.x, 0.0); - assert_eq!(expanded.width, 120.0); - - let shrunk = bounds.shrink(10.0).unwrap(); - assert_eq!(shrunk.x, 20.0); - assert_eq!(shrunk.width, 80.0); - } - - #[test] - fn test_scale() { - let bounds = Bounds::new(0.0, 0.0, 100.0, 100.0); - let scaled = bounds.scale(2.0); - - assert_eq!(scaled.width, 200.0); - assert_eq!(scaled.height, 200.0); - assert_eq!(scaled.center(), bounds.center()); - } -} diff --git a/src/domain/viewport/camera.rs b/src/domain/viewport/camera.rs deleted file mode 100644 index b20d1b1..0000000 --- a/src/domain/viewport/camera.rs +++ /dev/null @@ -1,236 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/domain/viewport/camera.rs -// -// Camera controls and transformations for viewport navigation. - -use super::viewport::Viewport; - -/// Camera pan direction. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PanDirection { - /// Pan left. - Left, - /// Pan right. - Right, - /// Pan up. - Up, - /// Pan down. - Down, -} - -/// Camera movement speed presets. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PanSpeed { - /// Slow pan (10% of viewport). - Slow, - /// Normal pan (25% of viewport). - Normal, - /// Fast pan (50% of viewport). - Fast, -} - -impl PanSpeed { - /// Get the multiplier for this speed. - #[must_use] - pub fn multiplier(self) -> f32 { - match self { - Self::Slow => 0.1, - Self::Normal => 0.25, - Self::Fast => 0.5, - } - } -} - -impl Default for PanSpeed { - fn default() -> Self { - Self::Normal - } -} - -/// Camera controller for viewport navigation. -/// -/// Provides high-level camera operations like directional panning, -/// smooth zooming, and bounds checking. -pub struct Camera { - /// Default pan speed. - pan_speed: PanSpeed, - /// Zoom step multiplier. - zoom_step: f32, -} - -impl Camera { - /// Create a new camera controller with default settings. - #[must_use] - pub fn new() -> Self { - Self { - pan_speed: PanSpeed::default(), - zoom_step: 1.25, - } - } - - /// Set the default pan speed. - pub fn set_pan_speed(&mut self, speed: PanSpeed) { - self.pan_speed = speed; - } - - /// Set the zoom step multiplier. - pub fn set_zoom_step(&mut self, step: f32) { - self.zoom_step = step.max(1.01); - } - - /// Pan the viewport in a specific direction. - /// - /// The pan amount is calculated as a percentage of the canvas size - /// based on the current pan speed. - pub fn pan(&self, viewport: &mut Viewport, direction: PanDirection) { - self.pan_with_speed(viewport, direction, self.pan_speed); - } - - /// Pan with a specific speed. - pub fn pan_with_speed( - &self, - viewport: &mut Viewport, - direction: PanDirection, - speed: PanSpeed, - ) { - let (canvas_width, canvas_height) = viewport.canvas_size(); - let multiplier = speed.multiplier(); - - let (dx, dy) = match direction { - PanDirection::Left => (canvas_width * multiplier, 0.0), - PanDirection::Right => (-canvas_width * multiplier, 0.0), - PanDirection::Up => (0.0, canvas_height * multiplier), - PanDirection::Down => (0.0, -canvas_height * multiplier), - }; - - viewport.pan_by(dx, dy); - } - - /// Zoom in using the default zoom step. - pub fn zoom_in(&self, viewport: &mut Viewport) { - viewport.zoom_in(self.zoom_step); - } - - /// Zoom out using the default zoom step. - pub fn zoom_out(&self, viewport: &mut Viewport) { - viewport.zoom_out(self.zoom_step); - } - - /// Zoom to a specific scale factor. - pub fn zoom_to(&self, viewport: &mut Viewport, scale: f32) { - viewport.set_scale(scale); - } - - /// Center the document in the viewport. - pub fn center(&self, viewport: &mut Viewport) { - viewport.reset_pan(); - } - - /// Calculate pan delta to center a specific point in the viewport. - /// - /// Returns (dx, dy) to apply to pan offset. - #[must_use] - pub fn calculate_pan_to_center_point( - &self, - viewport: &Viewport, - doc_x: f32, - doc_y: f32, - ) -> (f32, f32) { - let (canvas_width, canvas_height) = viewport.canvas_size(); - let _scale = viewport.scale(); - - // Convert document point to screen space - let (screen_x, screen_y) = viewport.document_to_screen(doc_x, doc_y); - - // Calculate delta to center point - let center_x = canvas_width / 2.0; - let center_y = canvas_height / 2.0; - - (center_x - screen_x, center_y - screen_y) - } - - /// Pan to center a specific document point in the viewport. - pub fn pan_to_center_point(&self, viewport: &mut Viewport, doc_x: f32, doc_y: f32) { - let (dx, dy) = self.calculate_pan_to_center_point(viewport, doc_x, doc_y); - viewport.pan_by(dx, dy); - } - - /// Zoom to a specific point (zoom centered on that point). - pub fn zoom_at_point( - &self, - viewport: &mut Viewport, - screen_x: f32, - screen_y: f32, - zoom_factor: f32, - ) { - // Convert screen point to document coordinates before zoom - let (doc_x, doc_y) = viewport.screen_to_document(screen_x, screen_y); - - // Apply zoom - let old_scale = viewport.scale(); - let new_scale = old_scale * zoom_factor; - viewport.set_scale(new_scale); - - // Convert document point back to screen coordinates after zoom - let (new_screen_x, new_screen_y) = viewport.document_to_screen(doc_x, doc_y); - - // Calculate pan adjustment to keep point under cursor - let dx = screen_x - new_screen_x; - let dy = screen_y - new_screen_y; - - viewport.pan_by(dx, dy); - } -} - -impl Default for Camera { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_camera_creation() { - let camera = Camera::new(); - assert_eq!(camera.pan_speed, PanSpeed::Normal); - assert_eq!(camera.zoom_step, 1.25); - } - - #[test] - fn test_pan_speed_multiplier() { - assert_eq!(PanSpeed::Slow.multiplier(), 0.1); - assert_eq!(PanSpeed::Normal.multiplier(), 0.25); - assert_eq!(PanSpeed::Fast.multiplier(), 0.5); - } - - #[test] - fn test_pan_direction() { - let camera = Camera::new(); - let mut viewport = Viewport::new(); - viewport.set_canvas_size(800.0, 600.0); - - camera.pan(&mut viewport, PanDirection::Right); - let (pan_x, _) = viewport.pan_offset(); - assert!(pan_x < 0.0); // Right pan moves content left - - camera.pan(&mut viewport, PanDirection::Left); - let (pan_x, _) = viewport.pan_offset(); - assert_eq!(pan_x, 0.0); // Should cancel out - } - - #[test] - fn test_zoom() { - let camera = Camera::new(); - let mut viewport = Viewport::new(); - viewport.set_scale(1.0); - - camera.zoom_in(&mut viewport); - assert_eq!(viewport.scale(), 1.25); - - camera.zoom_out(&mut viewport); - assert_eq!(viewport.scale(), 1.0); - } -} diff --git a/src/domain/viewport/mod.rs b/src/domain/viewport/mod.rs deleted file mode 100644 index 7335092..0000000 --- a/src/domain/viewport/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/domain/viewport/mod.rs -// -// Viewport domain: camera, bounds, and view state management. - -pub mod bounds; -pub mod camera; -pub mod viewport; diff --git a/src/domain/viewport/viewport.rs b/src/domain/viewport/viewport.rs deleted file mode 100644 index 166a450..0000000 --- a/src/domain/viewport/viewport.rs +++ /dev/null @@ -1,300 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/domain/viewport/viewport.rs -// -// Viewport state and transformations for document viewing. - -/// View mode for document display. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ViewMode { - /// Fit entire document in viewport. - Fit, - /// Display at actual size (1:1 pixel ratio). - ActualSize, - /// Custom zoom level. - Custom, -} - -impl Default for ViewMode { - fn default() -> Self { - Self::Fit - } -} - -/// Viewport state for document display. -/// -/// Manages pan, zoom, and view mode transformations. -#[derive(Debug, Clone, PartialEq)] -pub struct Viewport { - /// Current view mode. - view_mode: ViewMode, - /// Pan offset X (in screen pixels). - pan_x: f32, - /// Pan offset Y (in screen pixels). - pan_y: f32, - /// Current scale factor. - scale: f32, - /// Canvas dimensions (viewport size). - canvas_width: f32, - canvas_height: f32, - /// Document dimensions (content size). - document_width: f32, - document_height: f32, -} - -impl Viewport { - /// Create a new viewport with default settings. - #[must_use] - pub fn new() -> Self { - Self { - view_mode: ViewMode::Fit, - pan_x: 0.0, - pan_y: 0.0, - scale: 1.0, - canvas_width: 0.0, - canvas_height: 0.0, - document_width: 0.0, - document_height: 0.0, - } - } - - /// Set the canvas (viewport) dimensions. - pub fn set_canvas_size(&mut self, width: f32, height: f32) { - self.canvas_width = width; - self.canvas_height = height; - self.update_scale_if_fit(); - } - - /// Set the document dimensions. - pub fn set_document_size(&mut self, width: f32, height: f32) { - self.document_width = width; - self.document_height = height; - self.update_scale_if_fit(); - } - - /// Get the current view mode. - #[must_use] - pub fn view_mode(&self) -> ViewMode { - self.view_mode - } - - /// Set the view mode. - pub fn set_view_mode(&mut self, mode: ViewMode) { - self.view_mode = mode; - match mode { - ViewMode::Fit => { - self.reset_pan(); - self.update_scale_if_fit(); - } - ViewMode::ActualSize => { - self.reset_pan(); - self.scale = 1.0; - } - ViewMode::Custom => { - // Keep current scale and pan - } - } - } - - /// Get the current scale factor. - #[must_use] - pub fn scale(&self) -> f32 { - self.scale - } - - /// Set the scale factor (switches to Custom mode). - pub fn set_scale(&mut self, scale: f32) { - self.scale = scale.max(0.01); // Minimum scale - self.view_mode = ViewMode::Custom; - } - - /// Zoom in by a factor. - pub fn zoom_in(&mut self, factor: f32) { - self.set_scale(self.scale * factor); - } - - /// Zoom out by a factor. - pub fn zoom_out(&mut self, factor: f32) { - self.set_scale(self.scale / factor); - } - - /// Get pan offset. - #[must_use] - pub fn pan_offset(&self) -> (f32, f32) { - (self.pan_x, self.pan_y) - } - - /// Set pan offset. - pub fn set_pan(&mut self, x: f32, y: f32) { - self.pan_x = x; - self.pan_y = y; - if self.view_mode == ViewMode::Fit { - self.view_mode = ViewMode::Custom; - } - } - - /// Pan by a delta. - pub fn pan_by(&mut self, dx: f32, dy: f32) { - self.pan_x += dx; - self.pan_y += dy; - if self.view_mode == ViewMode::Fit { - self.view_mode = ViewMode::Custom; - } - } - - /// Reset pan to center. - pub fn reset_pan(&mut self) { - self.pan_x = 0.0; - self.pan_y = 0.0; - } - - /// Get canvas dimensions. - #[must_use] - pub fn canvas_size(&self) -> (f32, f32) { - (self.canvas_width, self.canvas_height) - } - - /// Get document dimensions. - #[must_use] - pub fn document_size(&self) -> (f32, f32) { - (self.document_width, self.document_height) - } - - /// Get scaled document dimensions. - #[must_use] - pub fn scaled_document_size(&self) -> (f32, f32) { - ( - self.document_width * self.scale, - self.document_height * self.scale, - ) - } - - /// Calculate the scale to fit the document in the viewport. - #[must_use] - pub fn calculate_fit_scale(&self) -> f32 { - if self.document_width == 0.0 || self.document_height == 0.0 { - return 1.0; - } - - let width_scale = self.canvas_width / self.document_width; - let height_scale = self.canvas_height / self.document_height; - - width_scale.min(height_scale) - } - - /// Update scale to fit mode if currently in fit mode. - fn update_scale_if_fit(&mut self) { - if self.view_mode == ViewMode::Fit { - self.scale = self.calculate_fit_scale(); - } - } - - /// Convert screen coordinates to document coordinates. - #[must_use] - pub fn screen_to_document(&self, screen_x: f32, screen_y: f32) -> (f32, f32) { - let (scaled_width, scaled_height) = self.scaled_document_size(); - - // Calculate document position in canvas - let doc_x = (self.canvas_width - scaled_width) / 2.0 + self.pan_x; - let doc_y = (self.canvas_height - scaled_height) / 2.0 + self.pan_y; - - // Convert screen to document coordinates - let rel_x = screen_x - doc_x; - let rel_y = screen_y - doc_y; - - (rel_x / self.scale, rel_y / self.scale) - } - - /// Convert document coordinates to screen coordinates. - #[must_use] - pub fn document_to_screen(&self, doc_x: f32, doc_y: f32) -> (f32, f32) { - let (scaled_width, scaled_height) = self.scaled_document_size(); - - // Calculate document position in canvas - let offset_x = (self.canvas_width - scaled_width) / 2.0 + self.pan_x; - let offset_y = (self.canvas_height - scaled_height) / 2.0 + self.pan_y; - - ( - offset_x + doc_x * self.scale, - offset_y + doc_y * self.scale, - ) - } - - /// Get the visible bounds of the document in document coordinates. - /// - /// Returns (x, y, width, height) of the visible region. - #[must_use] - pub fn visible_bounds(&self) -> (f32, f32, f32, f32) { - let (top_left_x, top_left_y) = self.screen_to_document(0.0, 0.0); - let (bottom_right_x, bottom_right_y) = - self.screen_to_document(self.canvas_width, self.canvas_height); - - let x = top_left_x.max(0.0); - let y = top_left_y.max(0.0); - let width = (bottom_right_x - top_left_x).min(self.document_width - x); - let height = (bottom_right_y - top_left_y).min(self.document_height - y); - - (x, y, width, height) - } - - /// Reset viewport to default state. - pub fn reset(&mut self) { - self.view_mode = ViewMode::Fit; - self.pan_x = 0.0; - self.pan_y = 0.0; - self.update_scale_if_fit(); - } -} - -impl Default for Viewport { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_viewport_creation() { - let viewport = Viewport::new(); - assert_eq!(viewport.view_mode(), ViewMode::Fit); - assert_eq!(viewport.scale(), 1.0); - assert_eq!(viewport.pan_offset(), (0.0, 0.0)); - } - - #[test] - fn test_fit_scale_calculation() { - let mut viewport = Viewport::new(); - viewport.set_canvas_size(800.0, 600.0); - viewport.set_document_size(1600.0, 1200.0); - - assert_eq!(viewport.calculate_fit_scale(), 0.5); - } - - #[test] - fn test_zoom() { - let mut viewport = Viewport::new(); - viewport.set_scale(1.0); - - viewport.zoom_in(2.0); - assert_eq!(viewport.scale(), 2.0); - assert_eq!(viewport.view_mode(), ViewMode::Custom); - - viewport.zoom_out(2.0); - assert_eq!(viewport.scale(), 1.0); - } - - #[test] - fn test_coordinate_conversion() { - let mut viewport = Viewport::new(); - viewport.set_canvas_size(800.0, 600.0); - viewport.set_document_size(400.0, 300.0); - viewport.set_scale(1.0); - - // Document should be centered in canvas - let (screen_x, screen_y) = viewport.document_to_screen(0.0, 0.0); - assert_eq!(screen_x, 200.0); // (800 - 400) / 2 - assert_eq!(screen_y, 150.0); // (600 - 300) / 2 - } -}