Before UI simplification - Clean Architecture complete
This commit is contained in:
parent
fc73e4b76b
commit
6a4629bb47
13 changed files with 69 additions and 1848 deletions
577
MIGRATION.md
577
MIGRATION.md
|
|
@ -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<cosmic_config::Config>,
|
|
||||||
|
|
||||||
// ✅ DocumentManager integriert
|
|
||||||
pub document_manager: DocumentManager,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl cosmic::Application for NoctuaApp {
|
|
||||||
fn init(mut core: Core, flags: Self::Flags) -> (Self, Task<Action<Self::Message>>) {
|
|
||||||
// ...
|
|
||||||
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! 🚀
|
|
||||||
|
|
@ -5,8 +5,9 @@
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::domain::document::collection::DocumentCollection;
|
||||||
use crate::domain::document::core::content::DocumentContent;
|
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::domain::document::core::metadata::DocumentMeta;
|
||||||
use crate::infrastructure::filesystem::file_ops;
|
use crate::infrastructure::filesystem::file_ops;
|
||||||
use crate::infrastructure::loaders::DocumentLoaderFactory;
|
use crate::infrastructure::loaders::DocumentLoaderFactory;
|
||||||
|
|
@ -14,17 +15,12 @@ use crate::infrastructure::loaders::DocumentLoaderFactory;
|
||||||
/// Central document manager.
|
/// Central document manager.
|
||||||
///
|
///
|
||||||
/// Orchestrates document loading, metadata extraction, and folder navigation.
|
/// Orchestrates document loading, metadata extraction, and folder navigation.
|
||||||
|
/// Uses DocumentCollection (Domain Layer) for navigation logic.
|
||||||
pub struct DocumentManager {
|
pub struct DocumentManager {
|
||||||
/// Current document (if any).
|
/// Document collection for navigation (Domain Layer abstraction).
|
||||||
current_document: Option<DocumentContent>,
|
collection: DocumentCollection,
|
||||||
/// Current document path.
|
|
||||||
current_path: Option<PathBuf>,
|
|
||||||
/// Current document metadata.
|
/// Current document metadata.
|
||||||
current_metadata: Option<DocumentMeta>,
|
current_metadata: Option<DocumentMeta>,
|
||||||
/// Folder entries for navigation.
|
|
||||||
folder_entries: Vec<PathBuf>,
|
|
||||||
/// Current index in folder entries.
|
|
||||||
current_index: Option<usize>,
|
|
||||||
/// Document loader factory.
|
/// Document loader factory.
|
||||||
loader: DocumentLoaderFactory,
|
loader: DocumentLoaderFactory,
|
||||||
}
|
}
|
||||||
|
|
@ -34,11 +30,8 @@ impl DocumentManager {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
current_document: None,
|
collection: DocumentCollection::new(),
|
||||||
current_path: None,
|
|
||||||
current_metadata: None,
|
current_metadata: None,
|
||||||
folder_entries: Vec::new(),
|
|
||||||
current_index: None,
|
|
||||||
loader: DocumentLoaderFactory::new(),
|
loader: DocumentLoaderFactory::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -51,10 +44,11 @@ impl DocumentManager {
|
||||||
// Determine the actual file to open
|
// Determine the actual file to open
|
||||||
let file_path = if path.is_dir() {
|
let file_path = if path.is_dir() {
|
||||||
// Scan directory and find first supported file
|
// 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
|
self.collection
|
||||||
.first()
|
.current_path()
|
||||||
.ok_or_else(|| anyhow::anyhow!("No supported files found in directory"))?
|
.ok_or_else(|| anyhow::anyhow!("No supported files found in directory"))?
|
||||||
.clone()
|
.clone()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -70,13 +64,15 @@ impl DocumentManager {
|
||||||
// Scan folder for navigation if not already done
|
// Scan folder for navigation if not already done
|
||||||
if !path.is_dir() {
|
if !path.is_dir() {
|
||||||
if let Some(parent) = file_path.parent() {
|
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)
|
// Generate thumbnails for multi-page documents (PDF)
|
||||||
let mut document = document;
|
let mut document = document;
|
||||||
if document.is_multi_page() {
|
if document.is_multi_page() {
|
||||||
|
|
@ -86,8 +82,8 @@ impl DocumentManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.current_document = Some(document);
|
// Store document in collection
|
||||||
self.current_path = Some(file_path);
|
self.collection.set_current_document(document);
|
||||||
self.current_metadata = Some(metadata);
|
self.current_metadata = Some(metadata);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -96,26 +92,28 @@ impl DocumentManager {
|
||||||
/// Get the current document.
|
/// Get the current document.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn current_document(&self) -> Option<&DocumentContent> {
|
pub fn current_document(&self) -> Option<&DocumentContent> {
|
||||||
self.current_document.as_ref()
|
self.collection.current_document()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a mutable reference to the current document.
|
/// Get a mutable reference to the current document.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn current_document_mut(&mut self) -> Option<&mut DocumentContent> {
|
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).
|
/// Get thumbnail handle for a specific page (read-only access).
|
||||||
/// Returns None if the thumbnail hasn't been generated yet.
|
/// Returns None if the thumbnail hasn't been generated yet.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn get_thumbnail_handle(&self, page: usize) -> Option<cosmic::widget::image::Handle> {
|
pub fn get_thumbnail_handle(&self, page: usize) -> Option<cosmic::widget::image::Handle> {
|
||||||
self.current_document.as_ref()?.get_thumbnail_handle(page)
|
self.collection
|
||||||
|
.current_document()?
|
||||||
|
.get_thumbnail_handle(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current document path.
|
/// Get the current document path.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn current_path(&self) -> Option<&Path> {
|
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.
|
/// Get the current document metadata.
|
||||||
|
|
@ -124,38 +122,33 @@ impl DocumentManager {
|
||||||
self.current_metadata.as_ref()
|
self.current_metadata.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get folder entries for navigation.
|
/// Get all folder entries for navigation.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn folder_entries(&self) -> &[PathBuf] {
|
pub fn folder_entries(&self) -> &[PathBuf] {
|
||||||
&self.folder_entries
|
self.collection.paths()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current index in folder.
|
/// Get current index in folder.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn current_index(&self) -> Option<usize> {
|
pub fn current_index(&self) -> Option<usize> {
|
||||||
self.current_index
|
self.collection.current_index()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigate to the next document in the folder.
|
/// Navigate to the next document in the folder.
|
||||||
///
|
///
|
||||||
/// Wraps around to the first document when at the end.
|
/// Wraps around to the first document when at the end.
|
||||||
pub fn next_document(&mut self) -> Option<PathBuf> {
|
pub fn next_document(&mut self) -> Option<PathBuf> {
|
||||||
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;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_index = match self.current_index {
|
let next_path = self.collection.current_path()?.clone();
|
||||||
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();
|
|
||||||
if self.open_document(&next_path).is_ok() {
|
if self.open_document(&next_path).is_ok() {
|
||||||
Some(next_path)
|
Some(next_path)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -167,22 +160,18 @@ impl DocumentManager {
|
||||||
///
|
///
|
||||||
/// Wraps around to the last document when at the beginning.
|
/// Wraps around to the last document when at the beginning.
|
||||||
pub fn previous_document(&mut self) -> Option<PathBuf> {
|
pub fn previous_document(&mut self) -> Option<PathBuf> {
|
||||||
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;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_index = match self.current_index {
|
let prev_path = self.collection.current_path()?.clone();
|
||||||
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();
|
|
||||||
if self.open_document(&prev_path).is_ok() {
|
if self.open_document(&prev_path).is_ok() {
|
||||||
Some(prev_path)
|
Some(prev_path)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -193,77 +182,30 @@ impl DocumentManager {
|
||||||
/// Close the current document.
|
/// Close the current document.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn close_document(&mut self) {
|
pub fn close_document(&mut self) {
|
||||||
self.current_document = None;
|
self.collection.clear_current_document();
|
||||||
self.current_path = None;
|
|
||||||
self.current_metadata = None;
|
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.
|
/// Extract metadata from a document.
|
||||||
fn extract_metadata(&self, path: &Path, document: &DocumentContent) -> DocumentMeta {
|
fn extract_metadata(&self, path: &Path, document: &DocumentContent) -> DocumentMeta {
|
||||||
use crate::domain::document::core::metadata::{BasicMeta, DocumentMeta, ExifMeta};
|
// Use the document's own extract_meta() method
|
||||||
|
// This properly delegates to the type-specific implementation
|
||||||
let info = document.info();
|
// (RasterDocument, VectorDocument, or PortableDocument)
|
||||||
let (width, height) = document.dimensions();
|
document.extract_meta(path)
|
||||||
|
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if there is a next document available.
|
/// Check if there is a next document available.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn has_next(&self) -> bool {
|
pub fn has_next(&self) -> bool {
|
||||||
if let Some(current) = self.current_index {
|
self.collection.has_next()
|
||||||
current + 1 < self.folder_entries.len()
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if there is a previous document available.
|
/// Check if there is a previous document available.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn has_previous(&self) -> bool {
|
pub fn has_previous(&self) -> bool {
|
||||||
if let Some(current) = self.current_index {
|
self.collection.has_previous()
|
||||||
current > 0
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
|
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod document_manager;
|
pub mod document_manager;
|
||||||
pub mod queries;
|
|
||||||
pub mod services;
|
pub mod services;
|
||||||
|
|
||||||
// Re-export document manager
|
// Re-export document manager
|
||||||
|
|
|
||||||
|
|
@ -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<DocumentMeta>,
|
|
||||||
/// 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<ImageHandle>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<Option<PageInfo>> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -103,11 +103,12 @@ impl DocumentCollection {
|
||||||
/// Returns the new index if successful, None if already at the end.
|
/// Returns the new index if successful, None if already at the end.
|
||||||
pub fn next(&mut self) -> Option<usize> {
|
pub fn next(&mut self) -> Option<usize> {
|
||||||
if let Some(current) = self.current_index
|
if let Some(current) = self.current_index
|
||||||
&& current + 1 < self.paths.len() {
|
&& current + 1 < self.paths.len()
|
||||||
self.current_index = Some(current + 1);
|
{
|
||||||
self.current_document = None; // Clear document (needs reload)
|
self.current_index = Some(current + 1);
|
||||||
return self.current_index;
|
self.current_document = None; // Clear document (needs reload)
|
||||||
}
|
return self.current_index;
|
||||||
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,11 +117,12 @@ impl DocumentCollection {
|
||||||
/// Returns the new index if successful, None if already at the start.
|
/// Returns the new index if successful, None if already at the start.
|
||||||
pub fn previous(&mut self) -> Option<usize> {
|
pub fn previous(&mut self) -> Option<usize> {
|
||||||
if let Some(current) = self.current_index
|
if let Some(current) = self.current_index
|
||||||
&& current > 0 {
|
&& current > 0
|
||||||
self.current_index = Some(current - 1);
|
{
|
||||||
self.current_document = None; // Clear document (needs reload)
|
self.current_index = Some(current - 1);
|
||||||
return self.current_index;
|
self.current_document = None; // Clear document (needs reload)
|
||||||
}
|
return self.current_index;
|
||||||
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<String>,
|
|
||||||
},
|
|
||||||
/// 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<PathBuf>,
|
|
||||||
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<io::Error> for DomainError {
|
|
||||||
fn from(error: io::Error) -> Self {
|
|
||||||
Self::Io { path: None, error }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<String> 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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,8 +4,6 @@
|
||||||
// Domain layer: business logic, document abstractions, and viewport management.
|
// Domain layer: business logic, document abstractions, and viewport management.
|
||||||
|
|
||||||
pub mod document;
|
pub mod document;
|
||||||
pub mod errors;
|
|
||||||
pub mod viewport;
|
|
||||||
|
|
||||||
// Re-export core document types
|
// Re-export core document types
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
|
|
@ -13,6 +11,10 @@ pub use document::core::content::DocumentContent;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use document::core::metadata::DocumentMeta;
|
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.
|
// 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.
|
||||||
|
|
|
||||||
|
|
@ -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<Self> {
|
|
||||||
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<Self> {
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue