Compare commits
No commits in common. "main" and "local/yoda-noctua-pdf-navigation" have entirely different histories.
main
...
local/yoda
102 changed files with 2621 additions and 17009 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,7 +1,7 @@
|
||||||
.cargo/
|
.cargo/
|
||||||
|
Cargo.lock
|
||||||
*.pdb
|
*.pdb
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
tmp/
|
|
||||||
debug/
|
debug/
|
||||||
target/
|
target/
|
||||||
vendor/
|
vendor/
|
||||||
|
|
|
||||||
7880
Cargo.lock
generated
7880
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
78
Cargo.toml
78
Cargo.toml
|
|
@ -1,44 +1,38 @@
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
# Cargo.toml
|
# Cargo.toml
|
||||||
#
|
#
|
||||||
# Project metadata and dependencies for the Noctua document viewer.
|
# Noctua COSMIC desktop application
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "noctua"
|
name = "noctua"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2021"
|
||||||
description = "A wise document and image viewer for the COSMIC™ desktop"
|
description = "Noctua – a document viewer for the COSMIC™ desktop"
|
||||||
repository = "https://codeberg.org/wfx/noctua"
|
repository = "https://codeberg.org/wfx/noctua"
|
||||||
authors = ["Wolfgang Morawetz <wfx@mailbox.org>"]
|
authors = ["Wolfgang Morawetz <wfx@mailbox.org>"]
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
keywords = ["document", "image", "viewer", "pdf", "cosmic"]
|
|
||||||
categories = ["gui", "multimedia::graphics", "multimedia::images"]
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["image", "vector", "portable"]
|
default = ["image", "vector", "portable"]
|
||||||
image = ["dep:image", "dep:kamadak-exif"]
|
image = ["dep:image", "dep:kamadak-exif"]
|
||||||
vector = ["dep:resvg"]
|
vector = ["dep:resvg"]
|
||||||
portable = ["dep:poppler", "dep:cairo-rs"]
|
portable = ["dep:pdfium-render"]
|
||||||
full = ["image", "vector", "portable"]
|
full = ["image", "vector", "portable"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Error handling
|
# Serialization
|
||||||
anyhow = "1"
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
ron = "0.8"
|
||||||
|
|
||||||
# Feature-gated dependencies
|
# Feature-gated document backends
|
||||||
kamadak-exif = { version = "0.5.5", optional = true }
|
image = { version = "0.25", optional = true }
|
||||||
image = { version = "0.25.9", optional = true }
|
kamadak-exif = { version = "0.5", optional = true }
|
||||||
poppler = { version = "0.4", features = ["render"], optional = true }
|
pdfium-render = { version = "0.8", optional = true }
|
||||||
cairo-rs = { version = "0.18", features = ["png"], optional = true }
|
|
||||||
resvg = { version = "0.45", optional = true }
|
resvg = { version = "0.45", optional = true }
|
||||||
|
|
||||||
# Async / concurrency
|
|
||||||
futures-util = "0.3.31"
|
|
||||||
tokio = { version = "1.48.0", features = ["full"] }
|
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
log = "0.4.20"
|
log = "0.4"
|
||||||
simple_logger = "4.2.0"
|
env_logger = "0.11"
|
||||||
|
|
||||||
# i18n / localization
|
# i18n / localization
|
||||||
i18n-embed = { version = "0.16", features = [
|
i18n-embed = { version = "0.16", features = [
|
||||||
|
|
@ -47,41 +41,35 @@ i18n-embed = { version = "0.16", features = [
|
||||||
] }
|
] }
|
||||||
i18n-embed-fl = "0.10"
|
i18n-embed-fl = "0.10"
|
||||||
|
|
||||||
# Misc utilities
|
# Resource embedding
|
||||||
open = "5.3.2"
|
rust-embed = "8"
|
||||||
rust-embed = "8.8.0"
|
|
||||||
dirs = "5.0"
|
|
||||||
sha2 = "0.10"
|
|
||||||
clap = { version = "4.5.54", features = ["derive"] }
|
|
||||||
env_logger = "0.11.8"
|
|
||||||
wallpaper = "3.2"
|
|
||||||
|
|
||||||
[dependencies.libcosmic]
|
# Open files/URLs with the system default application
|
||||||
git = "https://github.com/pop-os/libcosmic.git"
|
open = "5"
|
||||||
# See https://github.com/pop-os/libcosmic/blob/master/Cargo.toml for available features.
|
|
||||||
|
# Thumbnail cache (freedesktop.org spec)
|
||||||
|
dirs = "5.0"
|
||||||
|
md5 = "0.7"
|
||||||
|
png = "0.17"
|
||||||
|
|
||||||
|
# CLI argument parsing
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
|
||||||
|
# Async runtime (required by libcosmic)
|
||||||
|
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
|
||||||
|
|
||||||
|
[dependencies.cosmic]
|
||||||
|
package = "libcosmic-yoda"
|
||||||
|
path = "../libcosmic"
|
||||||
|
default-features = false
|
||||||
features = [
|
features = [
|
||||||
# Accessibility support
|
|
||||||
"a11y",
|
"a11y",
|
||||||
# About widget for the app
|
|
||||||
"about",
|
"about",
|
||||||
# Uses cosmic-settings-daemon to watch for config file changes
|
|
||||||
"dbus-config",
|
"dbus-config",
|
||||||
# Support creating additional application windows.
|
|
||||||
"multi-window",
|
"multi-window",
|
||||||
# On app startup, focuses an existing instance if the app is already open
|
|
||||||
"single-instance",
|
"single-instance",
|
||||||
# Uses tokio as the executor for the runtime
|
|
||||||
"tokio",
|
"tokio",
|
||||||
# Windowing support for X11, Windows, Mac, & Redox
|
|
||||||
"winit",
|
"winit",
|
||||||
# Add Wayland support to winit
|
|
||||||
"wayland",
|
"wayland",
|
||||||
# GPU-accelerated rendering
|
|
||||||
"wgpu",
|
"wgpu",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Uncomment to test a locally-cloned libcosmic
|
|
||||||
[patch.'https://github.com/pop-os/libcosmic']
|
|
||||||
libcosmic-yoda = { path = "../libcosmic" }
|
|
||||||
cosmic-config = { path = "../libcosmic/cosmic-config" }
|
|
||||||
cosmic-theme = { path = "../libcosmic/cosmic-theme" }
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
# noctua
|
|
||||||
|
|
||||||
202
docs/features.md
202
docs/features.md
|
|
@ -1,202 +0,0 @@
|
||||||
# Noctua Features
|
|
||||||
|
|
||||||
This document describes the implemented and planned features of Noctua, a modern image viewer for the COSMIC desktop environment.
|
|
||||||
|
|
||||||
## Current Features
|
|
||||||
|
|
||||||
### Document Support
|
|
||||||
|
|
||||||
#### Raster Images (Implemented)
|
|
||||||
- **Formats**: PNG, JPEG, GIF, BMP, TIFF, WebP, and all formats supported by `image-rs`
|
|
||||||
- **Capabilities**:
|
|
||||||
- Full pixel-perfect rendering at 100% zoom
|
|
||||||
- Lossless transformations (rotate, flip)
|
|
||||||
- Real-time transformation preview
|
|
||||||
- EXIF metadata extraction
|
|
||||||
|
|
||||||
#### Vector Graphics (Implemented)
|
|
||||||
- **Formats**: SVG
|
|
||||||
- **Rendering**: High-quality rendering via `resvg` library
|
|
||||||
- **Capabilities**:
|
|
||||||
- Scalable display at any zoom level without quality loss
|
|
||||||
- Transformations (rotate, flip)
|
|
||||||
|
|
||||||
#### Portable Documents (Implemented)
|
|
||||||
- **Formats**: PDF
|
|
||||||
- **Rendering**: Full PDF rendering via poppler library
|
|
||||||
- **Multi-page navigation**: Browse through all pages of a document
|
|
||||||
- **Page thumbnails**: Left sidebar shows page previews (generated on demand)
|
|
||||||
- **Transformations**: Rotate and flip on rendered pages
|
|
||||||
|
|
||||||
### Navigation
|
|
||||||
|
|
||||||
#### Folder Navigation (Implemented)
|
|
||||||
- **Automatic folder scanning**: When opening an image, all supported images in the same folder are indexed
|
|
||||||
- **Quick navigation**:
|
|
||||||
- Arrow keys (Left/Right) to navigate between images
|
|
||||||
- Footer displays current position (e.g., "3 / 42")
|
|
||||||
- Seamless transitions between images
|
|
||||||
|
|
||||||
#### File Opening (Implemented)
|
|
||||||
- **Command-line arguments**: Open images directly from terminal
|
|
||||||
- **Default directory**: Configurable starting location (defaults to XDG Pictures)
|
|
||||||
- File dialog not yet implemented
|
|
||||||
|
|
||||||
### View Controls
|
|
||||||
|
|
||||||
#### Zoom (Implemented)
|
|
||||||
- **Mouse wheel**: Zoom in/out centered on cursor position
|
|
||||||
- **Keyboard shortcuts**:
|
|
||||||
- `+` or `=` - Zoom in
|
|
||||||
- `-` - Zoom out
|
|
||||||
- `1` - Reset to 100% (Actual Size)
|
|
||||||
- `f` - Fit to window
|
|
||||||
- **View modes**:
|
|
||||||
- **Fit**: Automatically scales image to fit window while preserving aspect ratio
|
|
||||||
- **Actual Size**: Displays image at 100% (1:1 pixel mapping)
|
|
||||||
- **Custom**: Any zoom level from 10% to 2000%
|
|
||||||
- **Footer display**: Real-time zoom percentage or "Fit" indicator
|
|
||||||
|
|
||||||
#### Pan (Implemented)
|
|
||||||
- **Mouse drag**: Click and drag to pan around zoomed images
|
|
||||||
- **Keyboard shortcuts**: `Ctrl + Arrow Keys` for precise panning
|
|
||||||
- **Smart boundaries**: Pan is automatically limited to image boundaries
|
|
||||||
- **Auto-center**: Images smaller than viewport are automatically centered
|
|
||||||
|
|
||||||
#### Bidirectional State Sync (Implemented)
|
|
||||||
- Mouse interactions update keyboard/button controls
|
|
||||||
- Keyboard/button controls update mouse interaction state
|
|
||||||
- No conflicts between input methods
|
|
||||||
|
|
||||||
### Transformations
|
|
||||||
|
|
||||||
#### Image Manipulation (Implemented)
|
|
||||||
- **Rotate**:
|
|
||||||
- `r` - Rotate 90° clockwise
|
|
||||||
- `Shift + r` - Rotate 90° counter-clockwise
|
|
||||||
- Toolbar buttons available
|
|
||||||
- **Flip**:
|
|
||||||
- `h` - Flip horizontally (mirror)
|
|
||||||
- `v` - Flip vertically
|
|
||||||
- Toolbar buttons available
|
|
||||||
- **Lossless operations**: All transformations preserve original image quality
|
|
||||||
- **Real-time preview**: Changes are immediately visible
|
|
||||||
|
|
||||||
### User Interface
|
|
||||||
|
|
||||||
#### COSMIC Integration (Implemented)
|
|
||||||
- **Native COSMIC design**: Follows COSMIC desktop design language
|
|
||||||
- **Theme support**: Automatically adapts to system light/dark theme
|
|
||||||
- **Header toolbar**:
|
|
||||||
- Left: Navigation controls (Previous/Next) and panel toggle
|
|
||||||
- Center: Transformation buttons (Rotate, Flip) - horizontally centered
|
|
||||||
- Right: Information panel toggle
|
|
||||||
- **Footer bar**:
|
|
||||||
- Zoom controls with buttons
|
|
||||||
- Current zoom level display
|
|
||||||
- Image dimensions
|
|
||||||
- Navigation position counter
|
|
||||||
|
|
||||||
#### Panels (Implemented)
|
|
||||||
- **Properties panel**:
|
|
||||||
- Image metadata display
|
|
||||||
- File information
|
|
||||||
- Action buttons:
|
|
||||||
- Set as Wallpaper (works with COSMIC, GNOME, KDE, XFCE, and tiling WMs)
|
|
||||||
- Open With… (planned)
|
|
||||||
- Show in Folder (planned)
|
|
||||||
- Toggle with `i` key or toolbar button
|
|
||||||
- **Navigation panel** (Left sidebar):
|
|
||||||
- Toggle with `n` key or toolbar button
|
|
||||||
- For multi-page documents (PDF): Shows page thumbnails
|
|
||||||
- Click to navigate to specific page
|
|
||||||
|
|
||||||
#### Keyboard Shortcuts (Implemented)
|
|
||||||
Full keyboard-driven workflow:
|
|
||||||
- Navigation: `←` `→`
|
|
||||||
- Zoom: `+` `-` `1` `f`
|
|
||||||
- Pan: `Ctrl + ←` `Ctrl + →` `Ctrl + ↑` `Ctrl + ↓`
|
|
||||||
- Transform: `r` `Shift+r` `h` `v`
|
|
||||||
- Panels: `i` `n`
|
|
||||||
- Actions: `w` (Set as Wallpaper)
|
|
||||||
|
|
||||||
### Desktop Integration
|
|
||||||
|
|
||||||
#### Wallpaper Support (Implemented)
|
|
||||||
- **Set as Wallpaper**: One-click wallpaper setting with cross-desktop compatibility
|
|
||||||
- **Supported desktop environments**:
|
|
||||||
- COSMIC Desktop (direct config file integration)
|
|
||||||
- GNOME (via gsettings)
|
|
||||||
- KDE Plasma (via wallpaper crate)
|
|
||||||
- XFCE (via wallpaper crate)
|
|
||||||
- Tiling window managers (via feh)
|
|
||||||
- **Multiple access methods**:
|
|
||||||
- Keyboard shortcut: `w`
|
|
||||||
- Icon button in Properties panel
|
|
||||||
- Tooltip support for discoverability
|
|
||||||
- **Automatic fallback**: Tries multiple methods until one succeeds
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
#### Persistent Settings (Implemented)
|
|
||||||
- **Panel states**: Remembers which panels were open
|
|
||||||
- **Default directory**: Customizable starting location
|
|
||||||
- **Settings location**: `~/.config/noctua/config.toml`
|
|
||||||
|
|
||||||
### Technical Features
|
|
||||||
|
|
||||||
#### Architecture (Implemented)
|
|
||||||
- **Clean separation**: View layer agnostic to document format
|
|
||||||
- **Polymorphic documents**: Single `DocumentContent` interface for all formats
|
|
||||||
- **Efficient rendering**: Leverages COSMIC's iced renderer
|
|
||||||
- **Type-safe transformations**: Compile-time guarantees for image operations
|
|
||||||
|
|
||||||
#### Performance (Implemented)
|
|
||||||
- **Lazy loading**: Images loaded on-demand
|
|
||||||
- **Efficient folder scanning**: Fast directory traversal
|
|
||||||
- **Minimal memory footprint**: Only active document kept in memory
|
|
||||||
- **Smooth zooming**: Hardware-accelerated rendering
|
|
||||||
|
|
||||||
## Planned Features
|
|
||||||
|
|
||||||
### High Priority
|
|
||||||
|
|
||||||
#### File Operations
|
|
||||||
- File dialog integration (OpenPath message prepared)
|
|
||||||
- Save transformed images
|
|
||||||
- Copy/Move/Delete operations
|
|
||||||
- Drag-and-drop support
|
|
||||||
|
|
||||||
### Medium Priority
|
|
||||||
|
|
||||||
#### Multi-format TIFF Support
|
|
||||||
- Multi-page TIFF navigation (infrastructure ready)
|
|
||||||
- Page thumbnails for TIFF (same as PDF)
|
|
||||||
|
|
||||||
#### Metadata Editing
|
|
||||||
- EXIF data modification
|
|
||||||
- Comment annotations
|
|
||||||
- Tag management
|
|
||||||
|
|
||||||
### Low Priority
|
|
||||||
|
|
||||||
#### Advanced Editing
|
|
||||||
- Crop tool (message prepared)
|
|
||||||
- Scale/Resize tool (message prepared)
|
|
||||||
- Basic color adjustments (brightness, contrast)
|
|
||||||
|
|
||||||
#### Enhanced Navigation
|
|
||||||
- Grid view for folder contents
|
|
||||||
- Quick jump to file
|
|
||||||
|
|
||||||
#### Slideshow Mode
|
|
||||||
- Auto-advance timer
|
|
||||||
- Configurable intervals
|
|
||||||
- Fullscreen support
|
|
||||||
|
|
||||||
## Feature Status Legend
|
|
||||||
|
|
||||||
- **Implemented**: Fully functional and tested
|
|
||||||
- **Planned**: Design complete, implementation pending
|
|
||||||
- **Partial**: Basic functionality exists, enhancements needed
|
|
||||||
- **In Progress**: Currently being developed
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 312 KiB |
193
docs/usage.md
193
docs/usage.md
|
|
@ -1,193 +0,0 @@
|
||||||
# Usage Guide
|
|
||||||
|
|
||||||
Noctua is a modern image viewer for the COSMIC desktop environment with full keyboard support.
|
|
||||||
|
|
||||||
## Opening Images
|
|
||||||
|
|
||||||
### Command Line
|
|
||||||
Open an image directly from the terminal:
|
|
||||||
```bash
|
|
||||||
noctua /path/to/image.png
|
|
||||||
```
|
|
||||||
|
|
||||||
When you open an image, Noctua automatically scans the folder and indexes all supported images for quick navigation.
|
|
||||||
|
|
||||||
### Supported Formats
|
|
||||||
- **Raster Images**: PNG, JPEG, GIF, BMP, TIFF, WebP, and all formats supported by `image-rs`
|
|
||||||
- **Vector Graphics**: SVG (with scalable rendering)
|
|
||||||
- **Portable Documents**: PDF (with multi-page navigation and thumbnails)
|
|
||||||
|
|
||||||
## Keyboard Shortcuts
|
|
||||||
|
|
||||||
All keyboard shortcuts are case-insensitive unless otherwise noted.
|
|
||||||
|
|
||||||
### Navigation
|
|
||||||
|
|
||||||
| Key | Action | Description |
|
|
||||||
|:------|:------------------|:-----------------------------------------------|
|
|
||||||
| `←` | Previous image | Navigate to the previous image in the folder |
|
|
||||||
| `→` | Next image | Navigate to the next image in the folder |
|
|
||||||
|
|
||||||
The footer shows your current position (e.g., "3 / 42").
|
|
||||||
|
|
||||||
For multi-page documents (PDF):
|
|
||||||
- Click thumbnails in the left sidebar to jump to a specific page
|
|
||||||
- Use `←` `→` to navigate between pages
|
|
||||||
|
|
||||||
### Zoom and View
|
|
||||||
|
|
||||||
| Key | Action | Description |
|
|
||||||
|:----------|:---------------------------|:------------------------------------------------------|
|
|
||||||
| `+` / `=` | Zoom in | Increase zoom by 10% |
|
|
||||||
| `-` | Zoom out | Decrease zoom by ~9% |
|
|
||||||
| `1` | Actual size (100%) | Display image at pixel-perfect 1:1 scale |
|
|
||||||
| `f` | Fit to window | Scale image to fit the window while preserving ratio |
|
|
||||||
|
|
||||||
You can also zoom with the **mouse wheel** - the zoom centers on your cursor position.
|
|
||||||
|
|
||||||
The current zoom level is displayed in the footer (e.g., "150%" or "Fit").
|
|
||||||
|
|
||||||
### Pan
|
|
||||||
|
|
||||||
Pan controls allow you to move around zoomed images:
|
|
||||||
|
|
||||||
| Key | Action | Description |
|
|
||||||
|:-----------|:-------------------|:-----------------------------------------|
|
|
||||||
| `Ctrl + ←` | Pan left | Move view to the left |
|
|
||||||
| `Ctrl + →` | Pan right | Move view to the right |
|
|
||||||
| `Ctrl + ↑` | Pan up | Move view upward |
|
|
||||||
| `Ctrl + ↓` | Pan down | Move view downward |
|
|
||||||
| `0` | Reset pan | Center the image |
|
|
||||||
|
|
||||||
You can also **click and drag** with the mouse to pan around zoomed images.
|
|
||||||
|
|
||||||
### Transformations
|
|
||||||
|
|
||||||
| Key | Action | Description |
|
|
||||||
|:------------|:-------------------------------|:------------------------------------------|
|
|
||||||
| `h` | Flip horizontal | Mirror the image horizontally |
|
|
||||||
| `v` | Flip vertical | Flip the image upside down |
|
|
||||||
| `r` | Rotate clockwise | Rotate 90° clockwise |
|
|
||||||
| `Shift + r` | Rotate counter-clockwise | Rotate 90° counter-clockwise |
|
|
||||||
|
|
||||||
All transformations are lossless and show in real-time.
|
|
||||||
|
|
||||||
### Panels and UI
|
|
||||||
|
|
||||||
| Key | Action | Description |
|
|
||||||
|:----|:-----------------------|:-----------------------------------------|
|
|
||||||
| `i` | Toggle properties | Show/hide the properties panel (metadata)|
|
|
||||||
| `n` | Toggle navigation | Show/hide the navigation sidebar |
|
|
||||||
|
|
||||||
For multi-page documents (PDF), the navigation sidebar displays page thumbnails.
|
|
||||||
Click on a thumbnail to jump to that page.
|
|
||||||
|
|
||||||
### Actions
|
|
||||||
|
|
||||||
| Key | Action | Description |
|
|
||||||
|:----|:-----------------------|:-----------------------------------------|
|
|
||||||
| `w` | Set as wallpaper | Set the current image as desktop wallpaper|
|
|
||||||
|
|
||||||
## Mouse Controls
|
|
||||||
|
|
||||||
### Zoom
|
|
||||||
- **Mouse wheel up/down**: Zoom in/out centered on cursor
|
|
||||||
- **Footer buttons**: Click zoom in/out buttons for step-by-step control
|
|
||||||
|
|
||||||
### Pan
|
|
||||||
- **Click and drag**: Pan around zoomed images
|
|
||||||
- Hold and drag anywhere on the image to move the view
|
|
||||||
|
|
||||||
### Navigation
|
|
||||||
- **Footer navigation**: Use Previous/Next buttons to browse images
|
|
||||||
|
|
||||||
## Toolbar
|
|
||||||
|
|
||||||
The header toolbar provides quick access to common operations:
|
|
||||||
|
|
||||||
### Left Side
|
|
||||||
- **Navigation toggle**: Show/hide the sidebar
|
|
||||||
- **Previous/Next buttons**: Navigate between images in the folder
|
|
||||||
|
|
||||||
### Center (Horizontally Centered)
|
|
||||||
- **Rotate buttons**: Rotate clockwise or counter-clockwise
|
|
||||||
- **Flip buttons**: Flip horizontally or vertically
|
|
||||||
|
|
||||||
### Right Side
|
|
||||||
- **Properties toggle**: Show/hide the metadata panel
|
|
||||||
|
|
||||||
## Properties Panel
|
|
||||||
|
|
||||||
The properties panel (toggle with `i`) displays image metadata and provides quick actions:
|
|
||||||
|
|
||||||
### Action Buttons
|
|
||||||
Located at the top-right of the properties panel:
|
|
||||||
|
|
||||||
- **Set as Wallpaper** (`w` key): Set the current image as your desktop wallpaper
|
|
||||||
- Works with COSMIC, GNOME, KDE, XFCE, and tiling window managers
|
|
||||||
- Automatically detects your desktop environment
|
|
||||||
- Falls back to alternative methods if the primary method fails
|
|
||||||
|
|
||||||
- **Open With** (planned): Open the image with another application
|
|
||||||
|
|
||||||
- **Show in Folder** (planned): Open the containing folder in your file manager
|
|
||||||
|
|
||||||
### Metadata Display
|
|
||||||
- **File Information**: Name, format, dimensions, file size, color type
|
|
||||||
- **Camera Information** (if available): Camera model, date taken, exposure settings, GPS location
|
|
||||||
|
|
||||||
## Footer Information
|
|
||||||
|
|
||||||
The footer displays useful information:
|
|
||||||
- **Zoom controls**: Zoom out, current zoom level, zoom in, fit buttons
|
|
||||||
- **Image dimensions**: Width × Height in pixels
|
|
||||||
- **Navigation position**: Current image / Total images in folder
|
|
||||||
|
|
||||||
For PDFs, it also shows the current page number.
|
|
||||||
|
|
||||||
## Tips and Tricks
|
|
||||||
|
|
||||||
### Keyboard-Driven Workflow
|
|
||||||
Noctua is designed for efficient keyboard use:
|
|
||||||
1. Open an image from terminal
|
|
||||||
2. Use `←` `→` to browse through the folder
|
|
||||||
3. Press `r` to rotate, `h` or `v` to flip
|
|
||||||
4. Use `+` `-` to zoom, `Ctrl + arrows` to pan
|
|
||||||
5. Press `i` to check metadata
|
|
||||||
|
|
||||||
### Zoom and Pan Together
|
|
||||||
- Zoom with mouse wheel while hovering over the area you want to examine
|
|
||||||
- The zoom centers on your cursor, making it easy to focus on details
|
|
||||||
- Once zoomed, drag with mouse or use `Ctrl + arrows` to navigate
|
|
||||||
|
|
||||||
### Bidirectional Controls
|
|
||||||
Mouse and keyboard work together seamlessly:
|
|
||||||
- Zoom with keyboard, pan with mouse
|
|
||||||
- Zoom with mouse wheel, the footer updates automatically
|
|
||||||
- Pan with keyboard, continue with mouse drag
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Settings are stored in `~/.config/cosmic/org.codeberg.wfx.Noctua/v1/`.
|
|
||||||
|
|
||||||
### Configurable Options
|
|
||||||
- **Default directory**: Set your preferred starting location
|
|
||||||
- **Panel states**: Your panel preferences are remembered between sessions
|
|
||||||
|
|
||||||
## Planned Features
|
|
||||||
|
|
||||||
The following features are prepared in code but not yet implemented:
|
|
||||||
|
|
||||||
### File Operations
|
|
||||||
- File open dialog
|
|
||||||
- Save transformed images
|
|
||||||
- (Copy/Move/)Delete operations
|
|
||||||
|
|
||||||
### Document Support
|
|
||||||
- SVG rendering (implemented)
|
|
||||||
|
|
||||||
### Advanced Editing
|
|
||||||
- Crop mode (`c` key prepared)
|
|
||||||
- Scale/Resize mode (`s` key prepared)
|
|
||||||
|
|
||||||
See [features.md](features.md) for a complete list of planned features.
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
# i18n/en/noctua.ftl
|
|
||||||
#
|
|
||||||
# Localization strings for Noctua (English).
|
|
||||||
# Usage: fl!("message-id", arg1, arg2, ...)
|
|
||||||
#
|
|
||||||
# Positional arguments ($1, $2, ...) are used for variable content.
|
|
||||||
|
|
||||||
|
|
||||||
## Application
|
|
||||||
noctua-app-name = Noctua
|
|
||||||
noctua-app-description = Prohlížeč dokumentů a obrázků pro prostředí COSMIC
|
|
||||||
|
|
||||||
|
|
||||||
## Main window
|
|
||||||
window-title = { $filename ->
|
|
||||||
[none] Noctua
|
|
||||||
*[some] { $filename } — Noctua
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
## Menu entries
|
|
||||||
menu-file-open = Otevřít…
|
|
||||||
menu-file-quit = Ukončit
|
|
||||||
menu-view-zoom-in = Přiblížit
|
|
||||||
menu-view-zoom-out = Oddálit
|
|
||||||
menu-view-zoom-reset = Obnovit přiblížení
|
|
||||||
menu-view-zoom-fit = Přizpůsobit oknu
|
|
||||||
menu-view-flip-horizontal = Překlopit vodorovně
|
|
||||||
menu-view-flip-vertical = Překlopit svisle
|
|
||||||
menu-view-rotate-cw = Otočit po směru hodinových ručiček
|
|
||||||
menu-view-rotate-ccw = Otočit proti směru hodinových ručiček
|
|
||||||
|
|
||||||
|
|
||||||
## Tooltips (for buttons and icons)
|
|
||||||
tooltip-nav-previous = Předchozí dokument
|
|
||||||
tooltip-nav-next = Další dokument
|
|
||||||
tooltip-nav-toggle = Přepnout navigační panel
|
|
||||||
tooltip-zoom-in = Přiblížit
|
|
||||||
tooltip-zoom-out = Oddálit
|
|
||||||
tooltip-zoom-fit = Přizpůsobit oknu
|
|
||||||
tooltip-rotate-ccw = Otočit proti směru hodinových ručiček
|
|
||||||
tooltip-rotate-cw = Otočit po směru hodinových ručiček
|
|
||||||
tooltip-flip-horizontal = Překlopit vodorovně
|
|
||||||
tooltip-flip-vertical = Překlopit svisle
|
|
||||||
tooltip-info-panel = Přepnout informační panel
|
|
||||||
|
|
||||||
|
|
||||||
## Footer / Status bar
|
|
||||||
status-zoom-fit = Přizpůsobit
|
|
||||||
status-zoom-percent = { $percent }%
|
|
||||||
status-doc-dimensions = { $width } × { $height }
|
|
||||||
status-nav-position = { $current } / { $total }
|
|
||||||
status-separator = |
|
|
||||||
|
|
||||||
|
|
||||||
## Placeholders / Empty states
|
|
||||||
no-document = Není načten žádný dokument
|
|
||||||
|
|
||||||
|
|
||||||
## Labels
|
|
||||||
label-zoom = Přiblížení
|
|
||||||
label-tools = Nástroje
|
|
||||||
label-crop = Oříznout
|
|
||||||
label-scale = Měřítko
|
|
||||||
label-page = Stránka
|
|
||||||
label-pages = Stránky
|
|
||||||
|
|
||||||
|
|
||||||
## Loading states
|
|
||||||
loading-metadata = Načítání metadat…
|
|
||||||
loading-thumbnails = Načítání { $current } / { $total }…
|
|
||||||
|
|
||||||
|
|
||||||
## Error messages
|
|
||||||
error-failed-to-open = Nepodařilo se otevřít „{ $path }“
|
|
||||||
error-unsupported-format = Nepodporovaný formát souboru
|
|
||||||
error-no-image-loaded = Není načten žádný obrázek
|
|
||||||
|
|
||||||
|
|
||||||
## Properties panel
|
|
||||||
panel-properties = Vlastnosti
|
|
||||||
panel-actions = Akce
|
|
||||||
|
|
||||||
meta-section-file = Informace o souboru
|
|
||||||
meta-section-exif = Informace o kameře
|
|
||||||
meta-section-image = Informace o obrázku
|
|
||||||
|
|
||||||
## File metadata
|
|
||||||
meta-filename = Název
|
|
||||||
meta-format = Formát
|
|
||||||
meta-dimensions = Rozměry
|
|
||||||
meta-filesize = Velikost
|
|
||||||
meta-colortype = Typ barev
|
|
||||||
meta-path = Cesta
|
|
||||||
meta-pages = Stránky
|
|
||||||
meta-current-page = Aktuální stránka
|
|
||||||
|
|
||||||
## Image metadata
|
|
||||||
meta-width = Šířka
|
|
||||||
meta-height = Výška
|
|
||||||
meta-depth = Bitová hloubka
|
|
||||||
|
|
||||||
## EXIF metadata
|
|
||||||
meta-camera = Kamera
|
|
||||||
meta-datetime = Datum pořízení
|
|
||||||
meta-exposure = Expozice
|
|
||||||
meta-aperture = Clona
|
|
||||||
meta-iso = ISO { $iso }
|
|
||||||
meta-focal = Ohnisková vzdálenost
|
|
||||||
meta-gps = Poloha GPS
|
|
||||||
|
|
||||||
## Action buttons
|
|
||||||
action-set-wallpaper = Nastavit jako tapetu
|
|
||||||
action-open-with = Otevřít pomocí…
|
|
||||||
action-show-in-folder = Zobrazit ve složce
|
|
||||||
|
|
||||||
|
|
||||||
## Navigation panel (thumbnails)
|
|
||||||
nav-panel-title = Stránky
|
|
||||||
nav-panel-loading = Načítání { $current } / { $total }…
|
|
||||||
|
|
@ -33,20 +33,6 @@ menu-view-rotate-cw = Rotate Clockwise
|
||||||
menu-view-rotate-ccw = Rotate Counter-Clockwise
|
menu-view-rotate-ccw = Rotate Counter-Clockwise
|
||||||
|
|
||||||
|
|
||||||
## Tooltips (for buttons and icons)
|
|
||||||
tooltip-nav-previous = Previous document
|
|
||||||
tooltip-nav-next = Next document
|
|
||||||
tooltip-nav-toggle = Toggle navigation panel
|
|
||||||
tooltip-zoom-in = Zoom in
|
|
||||||
tooltip-zoom-out = Zoom out
|
|
||||||
tooltip-zoom-fit = Fit to window
|
|
||||||
tooltip-rotate-ccw = Rotate counter-clockwise
|
|
||||||
tooltip-rotate-cw = Rotate clockwise
|
|
||||||
tooltip-flip-horizontal = Flip horizontally
|
|
||||||
tooltip-flip-vertical = Flip vertically
|
|
||||||
tooltip-info-panel = Toggle info panel
|
|
||||||
|
|
||||||
|
|
||||||
## Footer / Status bar
|
## Footer / Status bar
|
||||||
status-zoom-fit = Fit
|
status-zoom-fit = Fit
|
||||||
status-zoom-percent = { $percent }%
|
status-zoom-percent = { $percent }%
|
||||||
|
|
@ -57,6 +43,11 @@ status-separator = |
|
||||||
|
|
||||||
## Placeholders / Empty states
|
## Placeholders / Empty states
|
||||||
no-document = No document loaded
|
no-document = No document loaded
|
||||||
|
no-document-hint = Open a document to view
|
||||||
|
|
||||||
|
## Status / Info
|
||||||
|
status-file-position = File { $current } / { $total }
|
||||||
|
status-page-position = Page { $current } / { $total }
|
||||||
|
|
||||||
|
|
||||||
## Labels
|
## Labels
|
||||||
|
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
# i18n/sv/noctua.ftl
|
|
||||||
#
|
|
||||||
# Localization strings for Noctua (Swedish).
|
|
||||||
# Usage: fl!("message-id", arg1, arg2, ...)
|
|
||||||
#
|
|
||||||
# Positional arguments ($1, $2, ...) are used for variable content.
|
|
||||||
|
|
||||||
|
|
||||||
## Program
|
|
||||||
noctua-app-name = Noctua
|
|
||||||
noctua-app-description = En dokument och bildvisare för COSMIC skrivbordet
|
|
||||||
|
|
||||||
## Main window
|
|
||||||
window-title = { $filename ->
|
|
||||||
[none] Noctua
|
|
||||||
*[some] { $filename } — Noctua
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
## Menyposter
|
|
||||||
menu-main = Meny
|
|
||||||
menu-file-open = Öppna…
|
|
||||||
menu-file-quit = Avsluta
|
|
||||||
menu-view-zoom-in = Zooma in
|
|
||||||
menu-view-zoom-out = Zooma ut
|
|
||||||
menu-view-zoom-reset = Återställ zoom
|
|
||||||
menu-view-zoom-fit = Anpassa till fönster
|
|
||||||
menu-view-flip-horizontal = Vänd horisontellt
|
|
||||||
menu-view-flip-vertical = Vänd vertikalt
|
|
||||||
menu-view-rotate-cw = Rotera medurs
|
|
||||||
menu-view-rotate-ccw = Rotera moturs
|
|
||||||
|
|
||||||
|
|
||||||
## Verktygstips (för knappar och ikoner)
|
|
||||||
tooltip-nav-previous = Föregående dokument
|
|
||||||
tooltip-nav-next = Nästa dokument
|
|
||||||
tooltip-nav-toggle = Växla navigeringspanel
|
|
||||||
tooltip-zoom-in = Zooma in
|
|
||||||
tooltip-zoom-out = Zooma ut
|
|
||||||
tooltip-zoom-fit = Anpassa till fönster
|
|
||||||
tooltip-rotate-ccw = Rotera moturs
|
|
||||||
tooltip-rotate-cw = Rotera medurs
|
|
||||||
tooltip-flip-horizontal = Vänd horisontellt
|
|
||||||
tooltip-flip-vertical = Vänd vertikalt
|
|
||||||
tooltip-info-panel = Växla informationspanel
|
|
||||||
|
|
||||||
|
|
||||||
## Sidfot / Statusfält
|
|
||||||
status-zoom-fit = Passa
|
|
||||||
status-zoom-percent = { $percent }%
|
|
||||||
status-doc-dimensions = { $width } × { $height }
|
|
||||||
status-nav-position = { $current } / { $total }
|
|
||||||
status-separator = |
|
|
||||||
|
|
||||||
|
|
||||||
## Platshållare / Tomma tillstånd
|
|
||||||
no-document = Inget dokument laddat
|
|
||||||
|
|
||||||
|
|
||||||
## Etiketter
|
|
||||||
label-zoom = Zoom
|
|
||||||
label-tools = Verktyg
|
|
||||||
label-crop = Beskära
|
|
||||||
label-scale = Skala
|
|
||||||
label-page = Sida
|
|
||||||
label-pages = Sidor
|
|
||||||
|
|
||||||
|
|
||||||
## Laddningstillstånd
|
|
||||||
loading-metadata = Laddar metadata…
|
|
||||||
loading-thumbnails = Laddar { $current } / { $total }…
|
|
||||||
|
|
||||||
|
|
||||||
## Felmeddelanden
|
|
||||||
error-failed-to-open = Misslyckades att öppna "{ $path }"
|
|
||||||
error-unsupported-format = Filformat som inte stöds
|
|
||||||
error-no-image-loaded = Ingen bild laddad
|
|
||||||
|
|
||||||
|
|
||||||
## Egenskapspanel
|
|
||||||
panel-properties = Egenskaper
|
|
||||||
panel-actions = Åtgärder
|
|
||||||
|
|
||||||
meta-section-file = Fil information
|
|
||||||
meta-section-exif = Kamera information
|
|
||||||
meta-section-image = Bild information
|
|
||||||
|
|
||||||
## Fil metadata
|
|
||||||
meta-filename = Namn
|
|
||||||
meta-format = Format
|
|
||||||
meta-dimensions = Dimensioner
|
|
||||||
meta-filesize = Storlek
|
|
||||||
meta-colortype = Färg-typ
|
|
||||||
meta-path = Sökväg
|
|
||||||
meta-pages = Sidor
|
|
||||||
meta-current-page = Nuvarande sida
|
|
||||||
|
|
||||||
## Bildmetadata
|
|
||||||
meta-width = Bredd
|
|
||||||
meta-height = Höjd
|
|
||||||
meta-depth = Bitdjup
|
|
||||||
|
|
||||||
## EXIF metadata
|
|
||||||
meta-camera = Kamera
|
|
||||||
meta-datetime = Datum taget
|
|
||||||
meta-exposure = Exponering
|
|
||||||
meta-aperture = Bländare
|
|
||||||
meta-iso = ISO { $iso }
|
|
||||||
meta-focal = Brännvidd
|
|
||||||
meta-gps = GPS plats
|
|
||||||
|
|
||||||
## Åtgärdsknappar
|
|
||||||
action-set-wallpaper = Använd som bakgrundsbild
|
|
||||||
action-open-with = Öppna med…
|
|
||||||
action-show-in-folder = Visa i mapp
|
|
||||||
|
|
||||||
|
|
||||||
## Navigeringspanel (tumnaglar)
|
|
||||||
nav-panel-title = Sidor
|
|
||||||
nav-panel-loading = Laddar { $current } / { $total }…
|
|
||||||
|
|
||||||
|
|
||||||
## Formatpanel
|
|
||||||
format-section-title = Pappersformat
|
|
||||||
format-section-subtitle = Välj pappersstorlek för export
|
|
||||||
orientation-section-title = Orientering
|
|
||||||
24
justfile
24
justfile
|
|
@ -1,4 +1,4 @@
|
||||||
# Name of the application's binary.
|
# Name of the application binary (produced by the ui crate).
|
||||||
name := 'noctua'
|
name := 'noctua'
|
||||||
# The unique ID of the application.
|
# The unique ID of the application.
|
||||||
appid := 'org.codeberg.wfx.Noctua'
|
appid := 'org.codeberg.wfx.Noctua'
|
||||||
|
|
@ -25,7 +25,7 @@ desktop-dst := base-dir / 'share' / 'applications' / desktop
|
||||||
icons-dst := base-dir / 'share' / 'icons' / 'hicolor'
|
icons-dst := base-dir / 'share' / 'icons' / 'hicolor'
|
||||||
icon-svg-dst := icons-dst / 'scalable' / 'apps' / icon-svg
|
icon-svg-dst := icons-dst / 'scalable' / 'apps' / icon-svg
|
||||||
|
|
||||||
# Default recipe which runs `just build-release`
|
# Default recipe
|
||||||
default: build-release
|
default: build-release
|
||||||
|
|
||||||
# Runs `cargo clean`
|
# Runs `cargo clean`
|
||||||
|
|
@ -39,9 +39,9 @@ clean-vendor:
|
||||||
# `cargo clean` and removes vendored dependencies
|
# `cargo clean` and removes vendored dependencies
|
||||||
clean-dist: clean clean-vendor
|
clean-dist: clean clean-vendor
|
||||||
|
|
||||||
# Compiles with debug profile
|
# Compiles the entire workspace with debug profile
|
||||||
build-debug *args:
|
build-debug *args:
|
||||||
cargo build {{args}}
|
cargo build --workspace {{args}}
|
||||||
|
|
||||||
# Compiles with release profile
|
# Compiles with release profile
|
||||||
build-release *args: (build-debug '--release' args)
|
build-release *args: (build-debug '--release' args)
|
||||||
|
|
@ -49,14 +49,18 @@ build-release *args: (build-debug '--release' args)
|
||||||
# Compiles release profile with vendored dependencies
|
# Compiles release profile with vendored dependencies
|
||||||
build-vendored *args: vendor-extract (build-release '--frozen --offline' args)
|
build-vendored *args: vendor-extract (build-release '--frozen --offline' args)
|
||||||
|
|
||||||
# Runs a clippy check
|
# Runs clippy on the entire workspace
|
||||||
check *args:
|
check *args:
|
||||||
cargo clippy --all-features {{args}} -- -W clippy::pedantic
|
cargo clippy --workspace --all-features {{args}} -- -W clippy::pedantic
|
||||||
|
|
||||||
# Runs a clippy check with JSON message format
|
# Runs clippy with JSON message format
|
||||||
check-json: (check '--message-format=json')
|
check-json: (check '--message-format=json')
|
||||||
|
|
||||||
# Run the application for testing purposes
|
# Runs tests on the entire workspace
|
||||||
|
test *args:
|
||||||
|
cargo test --workspace {{args}}
|
||||||
|
|
||||||
|
# Runs the application (no args = COSMIC UI, with args = CLI mode)
|
||||||
run *args:
|
run *args:
|
||||||
env RUST_BACKTRACE=full cargo run --release {{args}}
|
env RUST_BACKTRACE=full cargo run --release {{args}}
|
||||||
|
|
||||||
|
|
@ -91,10 +95,10 @@ vendor-extract:
|
||||||
rm -rf vendor
|
rm -rf vendor
|
||||||
tar pxf vendor.tar
|
tar pxf vendor.tar
|
||||||
|
|
||||||
# Bump cargo version, create git commit, and create tag
|
# Bump version in all crates, commit and tag
|
||||||
tag version:
|
tag version:
|
||||||
find -type f -name Cargo.toml -exec sed -i '0,/^version/s/^version.*/version = "{{version}}"/' '{}' \; -exec git add '{}' \;
|
find -type f -name Cargo.toml -exec sed -i '0,/^version/s/^version.*/version = "{{version}}"/' '{}' \; -exec git add '{}' \;
|
||||||
cargo check
|
cargo check --workspace
|
||||||
cargo clean
|
cargo clean
|
||||||
git add Cargo.lock
|
git add Cargo.lock
|
||||||
git commit -m 'release: {{version}}'
|
git commit -m 'release: {{version}}'
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Name=Noctua
|
Name=Noctua
|
||||||
Comment=A wise document and image viewer for the COSMIC™ desktop
|
Comment=A wise document and image viewer for the COSMIC™ desktop
|
||||||
Comment[cs]=Chytrý prohlížeč dokumentů a obrázku pro prostředí COSMIC™
|
|
||||||
Type=Application
|
Type=Application
|
||||||
Icon=org.codeberg.wfx.Noctua
|
Icon=org.codeberg.wfx.Noctua
|
||||||
Exec=noctua %F
|
Exec=/usr/bin/noctua %F
|
||||||
Terminal=false
|
Terminal=false
|
||||||
StartupNotify=true
|
StartupNotify=true
|
||||||
Categories=Graphics;Viewer;Utility;
|
Categories=Graphics;Viewer;Utility;
|
||||||
StartupWMClass=org.codeberg.wfx.Noctua
|
StartupWMClass=org.codeberg.wfx.Noctua
|
||||||
Keywords=image;document;pdf;viewer;cosmic;
|
Keywords=image;document;pdf;viewer;cosmic;
|
||||||
Keywords[cs]=obrázek;dokument;pdf;prohlížeč;fotka;
|
MimeType=image/png;image/x-png;image/jpeg;image/jpg;image/pjpeg;image/gif;image/webp;image/bmp;image/x-bmp;image/tiff;image/svg+xml;application/pdf;
|
||||||
MimeType=image/png;image/jpeg;image/gif;image/webp;image/bmp;image/tiff;image/svg+xml;application/pdf;
|
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,12 @@
|
||||||
<project_license>GPL-3.0-or-later</project_license>
|
<project_license>GPL-3.0-or-later</project_license>
|
||||||
<name>Noctua</name>
|
<name>Noctua</name>
|
||||||
<summary>A wise document and image viewer for the COSMIC™ desktop</summary>
|
<summary>A wise document and image viewer for the COSMIC™ desktop</summary>
|
||||||
<summary xml:lang="cs">Chytrý prohlížeč dokumentů a obrázků pro prostředí COSMIC™</summary>
|
|
||||||
|
|
||||||
<description>
|
<description>
|
||||||
<p>
|
<p>
|
||||||
Noctua is a lightweight, keyboard-driven viewer for images, PDFs, and vector graphics.
|
Noctua is a lightweight, keyboard-driven viewer for images, PDFs, and vector graphics.
|
||||||
Built with Rust and libcosmic, it integrates seamlessly with COSMIC™ and GTK-based desktops.
|
Built with Rust and libcosmic, it integrates seamlessly with COSMIC™ and GTK-based desktops.
|
||||||
</p>
|
</p>
|
||||||
<p xml:lang="cs">
|
|
||||||
Noctua je nenáročný prohlížeč obrázků, PDF a vektorové grafiky, snadno ovladatelný pomocí klávesnice.
|
|
||||||
Je napsaný v jazyce Rust a postavený na libcosmic, díky čemuž se plynule integruje s prostředím COSMIC™ a prostředími založenými na GTK.
|
|
||||||
</p>
|
|
||||||
</description>
|
</description>
|
||||||
|
|
||||||
<launchable type="desktop-id">org.codeberg.wfx.Noctua.desktop</launchable>
|
<launchable type="desktop-id">org.codeberg.wfx.Noctua.desktop</launchable>
|
||||||
|
|
@ -30,10 +25,14 @@
|
||||||
<provides>
|
<provides>
|
||||||
<binary>noctua</binary>
|
<binary>noctua</binary>
|
||||||
<mediatype>image/png</mediatype>
|
<mediatype>image/png</mediatype>
|
||||||
|
<mediatype>image/x-png</mediatype>
|
||||||
<mediatype>image/jpeg</mediatype>
|
<mediatype>image/jpeg</mediatype>
|
||||||
|
<mediatype>image/jpg</mediatype>
|
||||||
|
<mediatype>image/pjpeg</mediatype>
|
||||||
<mediatype>image/gif</mediatype>
|
<mediatype>image/gif</mediatype>
|
||||||
<mediatype>image/webp</mediatype>
|
<mediatype>image/webp</mediatype>
|
||||||
<mediatype>image/bmp</mediatype>
|
<mediatype>image/bmp</mediatype>
|
||||||
|
<mediatype>image/x-bmp</mediatype>
|
||||||
<mediatype>image/tiff</mediatype>
|
<mediatype>image/tiff</mediatype>
|
||||||
<mediatype>image/svg+xml</mediatype>
|
<mediatype>image/svg+xml</mediatype>
|
||||||
<mediatype>application/pdf</mediatype>
|
<mediatype>application/pdf</mediatype>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
# rustfmt.toml
|
|
||||||
reorder_imports = false
|
|
||||||
reorder_modules = false
|
|
||||||
imports_granularity = "Preserve"
|
|
||||||
group_imports = "Preserve"
|
|
||||||
165
src/active_state.rs
Normal file
165
src/active_state.rs
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// src/active_state.rs
|
||||||
|
//
|
||||||
|
// Pure navigation state: tracks which session and item are currently active.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::workspace::model::{Item, Session, Workspace};
|
||||||
|
|
||||||
|
/// Tracks the active session and item selection.
|
||||||
|
///
|
||||||
|
/// `ActiveState` does not own or store a `Workspace`. All methods that need
|
||||||
|
/// workspace data receive it as a parameter. This keeps navigation state
|
||||||
|
/// cleanly separated from domain data.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ActiveState {
|
||||||
|
active_session: Option<Uuid>,
|
||||||
|
active_item: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ActiveState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveState {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
active_session: None,
|
||||||
|
active_item: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all navigation state.
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.active_session = None;
|
||||||
|
self.active_item = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_session(&mut self, workspace: &Workspace, name: &str) -> Result<()> {
|
||||||
|
let session = workspace.session_by_name(name)?;
|
||||||
|
if self.active_session == Some(session.id) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.active_session = Some(session.id);
|
||||||
|
self.active_item = if session.items.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(0)
|
||||||
|
};
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_session_by_id(&mut self, workspace: &Workspace, id: Uuid) -> Result<()> {
|
||||||
|
if self.active_session == Some(id) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let session = workspace.session(id)?;
|
||||||
|
self.active_session = Some(session.id);
|
||||||
|
self.active_item = if session.items.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(0)
|
||||||
|
};
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_session(&mut self) {
|
||||||
|
self.active_session = None;
|
||||||
|
self.active_item = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_item(&mut self, workspace: &Workspace, index: usize) -> Result<()> {
|
||||||
|
let session = self.require_active_session(workspace)?;
|
||||||
|
if index >= session.items.len() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Item index {index} out of range (session {:?} has {} items)",
|
||||||
|
session.name,
|
||||||
|
session.items.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.active_item = Some(index);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_item(&mut self, workspace: &Workspace) {
|
||||||
|
let count = self.item_count(workspace);
|
||||||
|
if let Some(idx) = self.active_item {
|
||||||
|
if idx + 1 < count {
|
||||||
|
self.active_item = Some(idx + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn previous_item(&mut self) {
|
||||||
|
if let Some(idx) = self.active_item {
|
||||||
|
if idx > 0 {
|
||||||
|
self.active_item = Some(idx - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adjust the item cursor after an item was removed from the active session.
|
||||||
|
pub fn adjust_after_remove(&mut self, workspace: &Workspace) {
|
||||||
|
let count = self.item_count(workspace);
|
||||||
|
self.active_item = if count == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(self.active_item.unwrap_or(0).min(count - 1))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point the item cursor to the last item in the active session.
|
||||||
|
pub fn select_last_item(&mut self, workspace: &Workspace) {
|
||||||
|
let count = self.item_count(workspace);
|
||||||
|
self.active_item = if count == 0 { None } else { Some(count - 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Read-only accessors --
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn active_session_id(&self) -> Option<Uuid> {
|
||||||
|
self.active_session
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn active_item_index(&self) -> Option<usize> {
|
||||||
|
self.active_item
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the active session from the workspace.
|
||||||
|
#[must_use]
|
||||||
|
pub fn active_session<'a>(&self, workspace: &'a Workspace) -> Option<&'a Session> {
|
||||||
|
let id = self.active_session?;
|
||||||
|
workspace.session(id).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the active item from the active session.
|
||||||
|
#[must_use]
|
||||||
|
pub fn active_item<'a>(&self, workspace: &'a Workspace) -> Option<&'a Item> {
|
||||||
|
let session = self.active_session(workspace)?;
|
||||||
|
let idx = self.active_item?;
|
||||||
|
session.items.get(idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the UUID of the active item.
|
||||||
|
#[must_use]
|
||||||
|
pub fn active_item_id(&self, workspace: &Workspace) -> Option<Uuid> {
|
||||||
|
self.active_item(workspace).map(|i| i.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private helpers
|
||||||
|
|
||||||
|
fn require_active_session<'a>(&self, workspace: &'a Workspace) -> Result<&'a Session> {
|
||||||
|
let id = self.active_session.context("No active session")?;
|
||||||
|
workspace.session(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn item_count(&self, workspace: &Workspace) -> usize {
|
||||||
|
self.active_session(workspace).map_or(0, |s| s.items.len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,228 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/application/commands/crop_document.rs
|
|
||||||
//
|
|
||||||
// Crop document command: crop the current document to a specified region.
|
|
||||||
|
|
||||||
use cosmic::iced::{ContentFit, Size, Vector};
|
|
||||||
|
|
||||||
use crate::application::DocumentManager;
|
|
||||||
use crate::domain::document::core::content::DocumentKind;
|
|
||||||
use crate::domain::document::core::document::DocResult;
|
|
||||||
use crate::domain::document::operations::CropRegion;
|
|
||||||
|
|
||||||
/// Crop document command.
|
|
||||||
///
|
|
||||||
/// Crops the current document to the specified rectangular region.
|
|
||||||
/// The coordinates are in image pixels (not canvas/screen coordinates).
|
|
||||||
pub struct CropDocumentCommand {
|
|
||||||
/// X coordinate of the crop region (top-left corner).
|
|
||||||
pub x: u32,
|
|
||||||
/// Y coordinate of the crop region (top-left corner).
|
|
||||||
pub y: u32,
|
|
||||||
/// Width of the crop region in pixels.
|
|
||||||
pub width: u32,
|
|
||||||
/// Height of the crop region in pixels.
|
|
||||||
pub height: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CropDocumentCommand {
|
|
||||||
/// Create a new crop document command.
|
|
||||||
#[must_use]
|
|
||||||
pub fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
|
|
||||||
Self {
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a crop command from canvas coordinates.
|
|
||||||
///
|
|
||||||
/// Converts canvas-space coordinates to image-space pixels based on
|
|
||||||
/// the current view state (scale, pan, content fit).
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns an error if the crop region is invalid or outside image bounds.
|
|
||||||
pub fn from_canvas_selection(
|
|
||||||
crop_region: &CropRegion,
|
|
||||||
canvas_size: Size,
|
|
||||||
image_size: Size,
|
|
||||||
scale: f32,
|
|
||||||
pan_offset: Vector,
|
|
||||||
) -> Result<Self, String> {
|
|
||||||
let (x, y, w, h) = crop_region.as_tuple();
|
|
||||||
let canvas_rect = (x as f32, y as f32, w as f32, h as f32);
|
|
||||||
|
|
||||||
// Convert canvas coordinates to image pixel coordinates
|
|
||||||
let image_rect = Self::canvas_rect_to_image_rect(
|
|
||||||
canvas_rect,
|
|
||||||
canvas_size,
|
|
||||||
image_size,
|
|
||||||
scale,
|
|
||||||
pan_offset,
|
|
||||||
ContentFit::Contain,
|
|
||||||
)
|
|
||||||
.ok_or_else(|| "Invalid crop region".to_string())?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
x: image_rect.0,
|
|
||||||
y: image_rect.1,
|
|
||||||
width: image_rect.2,
|
|
||||||
height: image_rect.3,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert canvas rectangle to image pixel rectangle.
|
|
||||||
///
|
|
||||||
/// This is the core coordinate transformation logic that maps from
|
|
||||||
/// canvas/screen coordinates to actual image pixel coordinates.
|
|
||||||
fn canvas_rect_to_image_rect(
|
|
||||||
canvas_rect: (f32, f32, f32, f32),
|
|
||||||
canvas_size: Size,
|
|
||||||
image_size: Size,
|
|
||||||
scale: f32,
|
|
||||||
offset: Vector,
|
|
||||||
content_fit: ContentFit,
|
|
||||||
) -> Option<(u32, u32, u32, u32)> {
|
|
||||||
let (cx, cy, cw, ch) = canvas_rect;
|
|
||||||
|
|
||||||
if cw <= 1.0 || ch <= 1.0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transform top-left and bottom-right corners
|
|
||||||
let (x1, y1) = Self::canvas_to_image_coords(
|
|
||||||
cx,
|
|
||||||
cy,
|
|
||||||
canvas_size,
|
|
||||||
image_size,
|
|
||||||
scale,
|
|
||||||
offset,
|
|
||||||
content_fit,
|
|
||||||
);
|
|
||||||
let (x2, y2) = Self::canvas_to_image_coords(
|
|
||||||
cx + cw,
|
|
||||||
cy + ch,
|
|
||||||
canvas_size,
|
|
||||||
image_size,
|
|
||||||
scale,
|
|
||||||
offset,
|
|
||||||
content_fit,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clamp to image boundaries
|
|
||||||
let img_x = x1.max(0.0).min(image_size.width);
|
|
||||||
let img_y = y1.max(0.0).min(image_size.height);
|
|
||||||
let img_w = (x2 - x1).max(1.0).min(image_size.width - img_x);
|
|
||||||
let img_h = (y2 - y1).max(1.0).min(image_size.height - img_y);
|
|
||||||
|
|
||||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
|
||||||
Some((
|
|
||||||
img_x.round() as u32,
|
|
||||||
img_y.round() as u32,
|
|
||||||
img_w.round() as u32,
|
|
||||||
img_h.round() as u32,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert a single point from canvas coordinates to image coordinates.
|
|
||||||
fn canvas_to_image_coords(
|
|
||||||
cx: f32,
|
|
||||||
cy: f32,
|
|
||||||
canvas_size: Size,
|
|
||||||
image_size: Size,
|
|
||||||
scale: f32,
|
|
||||||
offset: Vector,
|
|
||||||
content_fit: ContentFit,
|
|
||||||
) -> (f32, f32) {
|
|
||||||
// Calculate displayed image dimensions based on ContentFit
|
|
||||||
let (display_w, display_h) = match content_fit {
|
|
||||||
ContentFit::Contain => {
|
|
||||||
let aspect = image_size.width / image_size.height;
|
|
||||||
let canvas_aspect = canvas_size.width / canvas_size.height;
|
|
||||||
|
|
||||||
if aspect > canvas_aspect {
|
|
||||||
// Limited by width
|
|
||||||
(canvas_size.width, canvas_size.width / aspect)
|
|
||||||
} else {
|
|
||||||
// Limited by height
|
|
||||||
(canvas_size.height * aspect, canvas_size.height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => (image_size.width, image_size.height),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Apply scale
|
|
||||||
let scaled_w = display_w * scale;
|
|
||||||
let scaled_h = display_h * scale;
|
|
||||||
|
|
||||||
// Center in canvas
|
|
||||||
let center_x = (canvas_size.width - scaled_w) / 2.0;
|
|
||||||
let center_y = (canvas_size.height - scaled_h) / 2.0;
|
|
||||||
|
|
||||||
// Convert canvas coords to scaled image coords
|
|
||||||
let img_x = (cx - center_x - offset.x) / scale;
|
|
||||||
let img_y = (cy - center_y - offset.y) / scale;
|
|
||||||
|
|
||||||
// Scale from display space to actual image pixel space
|
|
||||||
let pixel_x = (img_x / display_w) * image_size.width;
|
|
||||||
let pixel_y = (img_y / display_h) * image_size.height;
|
|
||||||
|
|
||||||
(pixel_x, pixel_y)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute the crop command on the document manager.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns an error if:
|
|
||||||
/// - No document is currently open
|
|
||||||
/// - The document type doesn't support cropping
|
|
||||||
/// - The crop region is invalid
|
|
||||||
/// - The crop operation fails
|
|
||||||
pub fn execute(&self, manager: &mut DocumentManager) -> DocResult<()> {
|
|
||||||
let doc = manager
|
|
||||||
.current_document_mut()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("No document open"))?;
|
|
||||||
|
|
||||||
// Only raster images support cropping
|
|
||||||
if doc.kind() != DocumentKind::Raster {
|
|
||||||
return Err(anyhow::anyhow!(
|
|
||||||
"Crop operation is only supported for raster images"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the raster document and apply crop
|
|
||||||
if let crate::domain::document::core::content::DocumentContent::Raster(raster) = doc {
|
|
||||||
raster
|
|
||||||
.crop(self.x, self.y, self.width, self.height)
|
|
||||||
.map_err(|e| anyhow::anyhow!("Crop failed: {}", e))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if the command can be executed.
|
|
||||||
#[must_use]
|
|
||||||
pub fn can_execute(&self, manager: &DocumentManager) -> bool {
|
|
||||||
manager
|
|
||||||
.current_document()
|
|
||||||
.map_or(false, |doc| doc.kind() == DocumentKind::Raster)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_command_creation() {
|
|
||||||
let cmd = CropDocumentCommand::new(10, 20, 100, 150);
|
|
||||||
assert_eq!(cmd.x, 10);
|
|
||||||
assert_eq!(cmd.y, 20);
|
|
||||||
assert_eq!(cmd.width, 100);
|
|
||||||
assert_eq!(cmd.height, 150);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/application/commands/mod.rs
|
|
||||||
//
|
|
||||||
// Application commands: document operations and navigation.
|
|
||||||
|
|
||||||
pub mod crop_document;
|
|
||||||
pub mod navigate;
|
|
||||||
pub mod open_document;
|
|
||||||
pub mod save_document;
|
|
||||||
pub mod transform_document;
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/application/commands/navigate.rs
|
|
||||||
//
|
|
||||||
// Navigation command: next/previous document.
|
|
||||||
// Reserved for future CQRS pattern - currently using direct DocumentManager methods.
|
|
||||||
|
|
||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use crate::application::document_manager::DocumentManager;
|
|
||||||
use crate::domain::document::core::document::DocResult;
|
|
||||||
|
|
||||||
/// Navigation direction.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum NavigationDirection {
|
|
||||||
/// Navigate to next document.
|
|
||||||
Next,
|
|
||||||
/// Navigate to previous document.
|
|
||||||
Previous,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Navigate command.
|
|
||||||
pub struct NavigateCommand {
|
|
||||||
direction: NavigationDirection,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NavigateCommand {
|
|
||||||
/// Create a new navigate command.
|
|
||||||
#[must_use]
|
|
||||||
pub fn new(direction: NavigationDirection) -> Self {
|
|
||||||
Self { direction }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute the navigate command.
|
|
||||||
pub fn execute(&self, manager: &mut DocumentManager) -> DocResult<Option<PathBuf>> {
|
|
||||||
let path = match self.direction {
|
|
||||||
NavigationDirection::Next => manager.next_document(),
|
|
||||||
NavigationDirection::Previous => manager.previous_document(),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if navigation is possible.
|
|
||||||
#[must_use]
|
|
||||||
pub fn can_execute(&self, manager: &DocumentManager) -> bool {
|
|
||||||
match self.direction {
|
|
||||||
NavigationDirection::Next => manager.has_next(),
|
|
||||||
NavigationDirection::Previous => manager.has_previous(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_navigate_command_creation() {
|
|
||||||
let cmd = NavigateCommand::new(NavigationDirection::Next);
|
|
||||||
assert_eq!(cmd.direction, NavigationDirection::Next);
|
|
||||||
|
|
||||||
let cmd = NavigateCommand::new(NavigationDirection::Previous);
|
|
||||||
assert_eq!(cmd.direction, NavigationDirection::Previous);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/application/commands/open_document.rs
|
|
||||||
//
|
|
||||||
// Open document command: load a document from a file path.
|
|
||||||
// Reserved for future CQRS pattern - currently using direct DocumentManager methods.
|
|
||||||
|
|
||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use crate::application::document_manager::DocumentManager;
|
|
||||||
use crate::domain::document::core::document::DocResult;
|
|
||||||
|
|
||||||
/// Open document command.
|
|
||||||
pub struct OpenDocumentCommand;
|
|
||||||
|
|
||||||
impl OpenDocumentCommand {
|
|
||||||
/// Create a new open document command.
|
|
||||||
#[must_use]
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute the open document command.
|
|
||||||
pub fn execute(&self, manager: &mut DocumentManager, path: &Path) -> DocResult<()> {
|
|
||||||
manager.open_document(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for OpenDocumentCommand {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/application/commands/save_document.rs
|
|
||||||
//
|
|
||||||
// Save document command: export document to a file.
|
|
||||||
// Reserved for future implementation - not yet used.
|
|
||||||
|
|
||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use crate::application::document_manager::DocumentManager;
|
|
||||||
use crate::domain::document::core::document::DocResult;
|
|
||||||
use crate::domain::document::operations::export::ExportFormat;
|
|
||||||
|
|
||||||
/// Save document command.
|
|
||||||
pub struct SaveDocumentCommand {
|
|
||||||
/// Target format for export.
|
|
||||||
format: Option<ExportFormat>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SaveDocumentCommand {
|
|
||||||
/// Create a new save document command with automatic format detection.
|
|
||||||
#[must_use]
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self { format: None }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a save document command with a specific format.
|
|
||||||
#[must_use]
|
|
||||||
pub fn with_format(format: ExportFormat) -> Self {
|
|
||||||
Self {
|
|
||||||
format: Some(format),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute the save document command.
|
|
||||||
pub fn execute(&self, manager: &DocumentManager, path: &Path) -> DocResult<()> {
|
|
||||||
let _document = manager
|
|
||||||
.current_document()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("No document loaded"))?;
|
|
||||||
|
|
||||||
// Detect format from path or use specified format
|
|
||||||
let format = self
|
|
||||||
.format
|
|
||||||
.or_else(|| ExportFormat::from_path(path))
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Could not determine export format"))?;
|
|
||||||
|
|
||||||
// TODO: Implement actual save logic
|
|
||||||
// This would involve:
|
|
||||||
// 1. Getting the rendered image from the document
|
|
||||||
// 2. Applying any necessary transformations
|
|
||||||
// 3. Exporting to the target format
|
|
||||||
|
|
||||||
log::info!("Save to {} as {:?}", path.display(), format);
|
|
||||||
|
|
||||||
Err(anyhow::anyhow!("Save operation not yet implemented"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SaveDocumentCommand {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/application/commands/transform_document.rs
|
|
||||||
//
|
|
||||||
// Transform document command: rotate, flip, and other transformations.
|
|
||||||
|
|
||||||
use crate::application::document_manager::DocumentManager;
|
|
||||||
use crate::domain::document::core::document::{DocResult, Rotation};
|
|
||||||
use crate::domain::document::operations::transform;
|
|
||||||
|
|
||||||
/// Transformation operation.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub enum TransformOperation {
|
|
||||||
/// Rotate clockwise by 90 degrees.
|
|
||||||
RotateCw,
|
|
||||||
/// Rotate counter-clockwise by 90 degrees.
|
|
||||||
RotateCcw,
|
|
||||||
/// Flip horizontally.
|
|
||||||
FlipHorizontal,
|
|
||||||
/// Flip vertically.
|
|
||||||
FlipVertical,
|
|
||||||
/// Rotate to a specific angle.
|
|
||||||
RotateTo(Rotation),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Transform document command.
|
|
||||||
pub struct TransformDocumentCommand {
|
|
||||||
operation: TransformOperation,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TransformDocumentCommand {
|
|
||||||
/// Create a new transform document command.
|
|
||||||
#[must_use]
|
|
||||||
pub fn new(operation: TransformOperation) -> Self {
|
|
||||||
Self { operation }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute the transform command.
|
|
||||||
///
|
|
||||||
/// Uses high-level transform operations that work across all document types
|
|
||||||
/// (Raster, Vector, Portable).
|
|
||||||
pub fn execute(&self, manager: &mut DocumentManager) -> DocResult<()> {
|
|
||||||
let document = manager
|
|
||||||
.current_document_mut()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("No document loaded"))?;
|
|
||||||
|
|
||||||
match self.operation {
|
|
||||||
TransformOperation::RotateCw => {
|
|
||||||
transform::rotate_document_cw(document)?;
|
|
||||||
}
|
|
||||||
TransformOperation::RotateCcw => {
|
|
||||||
transform::rotate_document_ccw(document)?;
|
|
||||||
}
|
|
||||||
TransformOperation::FlipHorizontal => {
|
|
||||||
transform::flip_document_horizontal(document)?;
|
|
||||||
}
|
|
||||||
TransformOperation::FlipVertical => {
|
|
||||||
transform::flip_document_vertical(document)?;
|
|
||||||
}
|
|
||||||
TransformOperation::RotateTo(rotation) => {
|
|
||||||
transform::rotate_document_to(document, rotation)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_transform_command_creation() {
|
|
||||||
let cmd = TransformDocumentCommand::new(TransformOperation::RotateCw);
|
|
||||||
assert_eq!(cmd.operation, TransformOperation::RotateCw);
|
|
||||||
|
|
||||||
let cmd = TransformDocumentCommand::new(TransformOperation::FlipHorizontal);
|
|
||||||
assert_eq!(cmd.operation, TransformOperation::FlipHorizontal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/application/document_manager.rs
|
|
||||||
//
|
|
||||||
// Document manager: orchestrates document lifecycle and navigation.
|
|
||||||
|
|
||||||
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;
|
|
||||||
use crate::domain::document::core::metadata::DocumentMeta;
|
|
||||||
use crate::infrastructure::filesystem::file_ops;
|
|
||||||
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 {
|
|
||||||
/// Document collection for navigation (Domain Layer abstraction).
|
|
||||||
collection: DocumentCollection,
|
|
||||||
/// Current document metadata.
|
|
||||||
current_metadata: Option<DocumentMeta>,
|
|
||||||
/// Document loader factory.
|
|
||||||
loader: DocumentLoaderFactory,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DocumentManager {
|
|
||||||
/// Create a new document manager.
|
|
||||||
#[must_use]
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
collection: DocumentCollection::new(),
|
|
||||||
current_metadata: None,
|
|
||||||
loader: DocumentLoaderFactory::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open a document from a file path or directory.
|
|
||||||
///
|
|
||||||
/// If a directory is provided, opens the first supported file found.
|
|
||||||
/// Also scans the parent folder for navigation.
|
|
||||||
pub fn open_document(&mut self, path: &Path) -> DocResult<()> {
|
|
||||||
// Determine the actual file to open
|
|
||||||
let file_path = if path.is_dir() {
|
|
||||||
// Scan directory and find first supported file
|
|
||||||
let paths = file_ops::collect_supported_files(path);
|
|
||||||
self.collection = DocumentCollection::from_paths(paths);
|
|
||||||
|
|
||||||
self.collection
|
|
||||||
.current_path()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("No supported files found in directory"))?
|
|
||||||
.clone()
|
|
||||||
} else {
|
|
||||||
path.to_path_buf()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load the document
|
|
||||||
let document = self.loader.load(&file_path)?;
|
|
||||||
|
|
||||||
// Extract metadata
|
|
||||||
let metadata = self.extract_metadata(&file_path, &document);
|
|
||||||
|
|
||||||
// Scan folder for navigation if not already done
|
|
||||||
if !path.is_dir() {
|
|
||||||
if let Some(parent) = file_path.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate thumbnails for multi-page documents (PDF)
|
|
||||||
let mut document = document;
|
|
||||||
if document.is_multi_page() {
|
|
||||||
log::info!("Generating thumbnails for multi-page document...");
|
|
||||||
if let Err(e) = document.generate_thumbnails() {
|
|
||||||
log::warn!("Failed to generate thumbnails: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store document in collection
|
|
||||||
self.collection.set_current_document(document);
|
|
||||||
self.current_metadata = Some(metadata);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current document.
|
|
||||||
#[must_use]
|
|
||||||
pub fn current_document(&self) -> Option<&DocumentContent> {
|
|
||||||
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.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<cosmic::widget::image::Handle> {
|
|
||||||
self.collection
|
|
||||||
.current_document()?
|
|
||||||
.get_thumbnail_handle(page)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current document path.
|
|
||||||
#[must_use]
|
|
||||||
pub fn current_path(&self) -> Option<&Path> {
|
|
||||||
self.collection.current_path().map(|p| p.as_path())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current document metadata.
|
|
||||||
#[must_use]
|
|
||||||
pub fn current_metadata(&self) -> Option<&DocumentMeta> {
|
|
||||||
self.current_metadata.as_ref()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all folder entries for navigation.
|
|
||||||
#[must_use]
|
|
||||||
pub fn folder_entries(&self) -> &[PathBuf] {
|
|
||||||
self.collection.paths()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get current index in folder.
|
|
||||||
#[must_use]
|
|
||||||
pub fn current_index(&self) -> Option<usize> {
|
|
||||||
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<PathBuf> {
|
|
||||||
// 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 next_path = self.collection.current_path()?.clone();
|
|
||||||
if self.open_document(&next_path).is_ok() {
|
|
||||||
Some(next_path)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Navigate to the previous document in the folder.
|
|
||||||
///
|
|
||||||
/// Wraps around to the last document when at the beginning.
|
|
||||||
pub fn previous_document(&mut self) -> Option<PathBuf> {
|
|
||||||
// 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 prev_path = self.collection.current_path()?.clone();
|
|
||||||
if self.open_document(&prev_path).is_ok() {
|
|
||||||
Some(prev_path)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Close the current document.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn close_document(&mut self) {
|
|
||||||
self.collection.clear_current_document();
|
|
||||||
self.current_metadata = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract metadata from a document.
|
|
||||||
fn extract_metadata(&self, path: &Path, document: &DocumentContent) -> DocumentMeta {
|
|
||||||
// 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 {
|
|
||||||
self.collection.has_next()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if there is a previous document available.
|
|
||||||
#[must_use]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn has_previous(&self) -> bool {
|
|
||||||
self.collection.has_previous()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for DocumentManager {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/application/mod.rs
|
|
||||||
//
|
|
||||||
// Application layer: use cases, commands, queries, and services.
|
|
||||||
|
|
||||||
pub mod commands;
|
|
||||||
pub mod document_manager;
|
|
||||||
pub mod services;
|
|
||||||
|
|
||||||
// Re-export document manager
|
|
||||||
pub use document_manager::DocumentManager;
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/application/services/cache_service.rs
|
|
||||||
//
|
|
||||||
// Cache service: manages document and thumbnail caching.
|
|
||||||
// Reserved for future caching layer implementation.
|
|
||||||
|
|
||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use cosmic::widget::image::Handle as ImageHandle;
|
|
||||||
use image::DynamicImage;
|
|
||||||
|
|
||||||
use crate::infrastructure::cache::ThumbnailCache;
|
|
||||||
|
|
||||||
/// Cache service for managing document caches.
|
|
||||||
///
|
|
||||||
/// Provides high-level caching operations for the application layer.
|
|
||||||
pub struct CacheService;
|
|
||||||
|
|
||||||
impl CacheService {
|
|
||||||
/// Create a new cache service.
|
|
||||||
#[must_use]
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load a thumbnail from cache.
|
|
||||||
///
|
|
||||||
/// Returns None if the thumbnail is not cached or the cache is invalid.
|
|
||||||
#[must_use]
|
|
||||||
pub fn get_thumbnail(&self, path: &Path, page: usize) -> Option<ImageHandle> {
|
|
||||||
ThumbnailCache::load(path, page)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save a thumbnail to cache.
|
|
||||||
///
|
|
||||||
/// Returns true if the thumbnail was successfully cached.
|
|
||||||
pub fn put_thumbnail(&self, path: &Path, page: usize, image: &DynamicImage) -> bool {
|
|
||||||
ThumbnailCache::save(path, page, image).is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all cached thumbnails.
|
|
||||||
///
|
|
||||||
/// This operation is not yet implemented.
|
|
||||||
pub fn clear_cache(&self) -> Result<(), String> {
|
|
||||||
ThumbnailCache::clear_cache().map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the size of the cache directory.
|
|
||||||
///
|
|
||||||
/// Returns the total size in bytes, or None if it cannot be determined.
|
|
||||||
#[must_use]
|
|
||||||
pub fn cache_size(&self) -> Option<u64> {
|
|
||||||
// TODO: Implement cache size calculation
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for CacheService {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_cache_service_creation() {
|
|
||||||
let service = CacheService::new();
|
|
||||||
assert!(std::ptr::eq(&service, &service)); // Dummy test
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_cache_service_default() {
|
|
||||||
let service = CacheService::default();
|
|
||||||
assert!(std::ptr::eq(&service, &service)); // Dummy test
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/application/services/mod.rs
|
|
||||||
//
|
|
||||||
// Application services: cache management and preview generation.
|
|
||||||
|
|
||||||
pub mod cache_service;
|
|
||||||
pub mod preview_service;
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/application/services/preview_service.rs
|
|
||||||
//
|
|
||||||
// Preview service: generates thumbnails and previews for documents.
|
|
||||||
// Reserved for future async thumbnail generation implementation.
|
|
||||||
|
|
||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
use cosmic::widget::image::Handle as ImageHandle;
|
|
||||||
|
|
||||||
use crate::domain::document::core::content::DocumentContent;
|
|
||||||
use crate::domain::document::core::document::DocResult;
|
|
||||||
|
|
||||||
/// Preview service for generating document thumbnails and previews.
|
|
||||||
///
|
|
||||||
/// Provides high-level preview generation operations for the application layer.
|
|
||||||
pub struct PreviewService {
|
|
||||||
/// Target thumbnail size (width in pixels).
|
|
||||||
thumbnail_size: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PreviewService {
|
|
||||||
/// Create a new preview service with default thumbnail size.
|
|
||||||
#[must_use]
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
thumbnail_size: 256,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a preview service with a specific thumbnail size.
|
|
||||||
#[must_use]
|
|
||||||
pub fn with_thumbnail_size(size: u32) -> Self {
|
|
||||||
Self {
|
|
||||||
thumbnail_size: size,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the thumbnail size.
|
|
||||||
pub fn set_thumbnail_size(&mut self, size: u32) {
|
|
||||||
self.thumbnail_size = size;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current thumbnail size.
|
|
||||||
#[must_use]
|
|
||||||
pub fn thumbnail_size(&self) -> u32 {
|
|
||||||
self.thumbnail_size
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate a thumbnail for a document page.
|
|
||||||
///
|
|
||||||
/// For single-page documents, the page parameter is ignored.
|
|
||||||
pub fn generate_thumbnail(
|
|
||||||
&self,
|
|
||||||
document: &mut DocumentContent,
|
|
||||||
page: usize,
|
|
||||||
) -> DocResult<Option<ImageHandle>> {
|
|
||||||
if document.is_multi_page() {
|
|
||||||
document.get_thumbnail(page)
|
|
||||||
} else {
|
|
||||||
// For single-page documents, return the current handle
|
|
||||||
Ok(document.handle())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate all thumbnails for a multi-page document.
|
|
||||||
///
|
|
||||||
/// Returns the number of thumbnails generated.
|
|
||||||
pub fn generate_all_thumbnails(&self, document: &mut DocumentContent) -> DocResult<usize> {
|
|
||||||
if !document.is_multi_page() {
|
|
||||||
return Ok(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.generate_thumbnails()?;
|
|
||||||
Ok(document.thumbnails_loaded())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if all thumbnails are ready for a document.
|
|
||||||
#[must_use]
|
|
||||||
pub fn thumbnails_ready(&self, document: &DocumentContent) -> bool {
|
|
||||||
document.thumbnails_ready()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the number of thumbnails loaded for a document.
|
|
||||||
#[must_use]
|
|
||||||
pub fn thumbnails_loaded(&self, document: &DocumentContent) -> usize {
|
|
||||||
document.thumbnails_loaded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for PreviewService {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_preview_service_creation() {
|
|
||||||
let service = PreviewService::new();
|
|
||||||
assert_eq!(service.thumbnail_size(), 256);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_preview_service_with_size() {
|
|
||||||
let service = PreviewService::with_thumbnail_size(512);
|
|
||||||
assert_eq!(service.thumbnail_size(), 512);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_set_thumbnail_size() {
|
|
||||||
let mut service = PreviewService::new();
|
|
||||||
service.set_thumbnail_size(128);
|
|
||||||
assert_eq!(service.thumbnail_size(), 128);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/config.rs
|
|
||||||
//
|
|
||||||
// Global configuration for the application with cosmic-config support.
|
|
||||||
|
|
||||||
use cosmic::cosmic_config::{self, CosmicConfigEntry, cosmic_config_derive::CosmicConfigEntry};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
/// Global configuration for the application.
|
|
||||||
#[derive(Debug, Clone, CosmicConfigEntry, PartialEq)]
|
|
||||||
#[version = 1]
|
|
||||||
pub struct AppConfig {
|
|
||||||
/// Default directory to open when browsing for documents.
|
|
||||||
pub default_image_dir: Option<PathBuf>,
|
|
||||||
/// Show page navigation panel (left sidebar for multi-page documents).
|
|
||||||
pub nav_bar_visible: bool,
|
|
||||||
/// Show properties panel (right sidebar with metadata).
|
|
||||||
pub context_drawer_visible: bool,
|
|
||||||
/// Zoom step multiplier for keyboard shortcuts (1.1 = 10% increase per step).
|
|
||||||
pub scale_step: f32,
|
|
||||||
/// Pan distance in pixels per arrow key press.
|
|
||||||
pub pan_step: f32,
|
|
||||||
/// Minimum zoom level (0.1 = 10% of original size).
|
|
||||||
pub min_scale: f32,
|
|
||||||
/// Maximum zoom level (8.0 = 800% of original size).
|
|
||||||
pub max_scale: f32,
|
|
||||||
/// Show 3x3 grid during crop selection.
|
|
||||||
pub crop_show_grid: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AppConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
default_image_dir: dirs::picture_dir().or_else(dirs::home_dir),
|
|
||||||
nav_bar_visible: false,
|
|
||||||
context_drawer_visible: false,
|
|
||||||
scale_step: 1.1,
|
|
||||||
pan_step: 50.0,
|
|
||||||
min_scale: 0.1,
|
|
||||||
max_scale: 8.0,
|
|
||||||
crop_show_grid: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
13
src/document/command.rs
Normal file
13
src/document/command.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// src/document/command.rs
|
||||||
|
//
|
||||||
|
// Commands that operate on the active document itself.
|
||||||
|
|
||||||
|
/// Commands operating on the active document (zoom, export, annotate, …).
|
||||||
|
///
|
||||||
|
/// The `DocumentManager` dispatches these after resolving which document is
|
||||||
|
/// active – the UI never needs to know the underlying format.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum DocumentCommand {
|
||||||
|
// Reserved for future use: Rotate, Flip, Zoom, Export, Annotate, …
|
||||||
|
}
|
||||||
101
src/document/loader.rs
Normal file
101
src/document/loader.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// src/document/loader.rs
|
||||||
|
//
|
||||||
|
// Document loading: format detection, format dispatch, and directory scanning.
|
||||||
|
|
||||||
|
use crate::document::model::{PortableModel, RasterModel, VectorModel};
|
||||||
|
use crate::document::session::data::{SessionData, SessionItem};
|
||||||
|
use crate::document::types::PageLayout;
|
||||||
|
use crate::document::DocumentContent;
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// The three document formats Noctua understands.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum DocumentFormat {
|
||||||
|
/// Raster images – anything the image crate can decode.
|
||||||
|
Raster,
|
||||||
|
/// Vector graphics (SVG).
|
||||||
|
Vector,
|
||||||
|
/// Portable documents (PDF).
|
||||||
|
Portable,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detects the document format from the file extension.
|
||||||
|
///
|
||||||
|
/// Returns `None` for unknown extensions so directory scans can skip
|
||||||
|
/// unsupported files early. At load time the underlying library
|
||||||
|
/// (`ImageReader`, `usvg`, `pdfium`) is the final authority.
|
||||||
|
fn detect_format(path: &Path) -> Option<DocumentFormat> {
|
||||||
|
match path
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(str::to_lowercase)
|
||||||
|
.as_deref()
|
||||||
|
{
|
||||||
|
Some("pdf") => Some(DocumentFormat::Portable),
|
||||||
|
Some("svg") => Some(DocumentFormat::Vector),
|
||||||
|
Some("png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "tiff" | "tif") => {
|
||||||
|
Some(DocumentFormat::Raster)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads a document from a path and returns its content.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`Error::NotFound`] if the path does not exist.
|
||||||
|
/// Returns [`Error::Unsupported`] if the file extension is not recognised.
|
||||||
|
pub(crate) fn load_document(path: &Path) -> Result<DocumentContent> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(Error::NotFound(format!("{}", path.display())));
|
||||||
|
}
|
||||||
|
|
||||||
|
match detect_format(path) {
|
||||||
|
Some(DocumentFormat::Portable) => {
|
||||||
|
// TODO: Replace with actual pdfium-render loading.
|
||||||
|
Ok(DocumentContent::Portable(PortableModel {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
page_count: 3,
|
||||||
|
layout: PageLayout::new(vec![(595.0, 842.0); 3], 10.0),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
Some(DocumentFormat::Vector) => {
|
||||||
|
// TODO: Replace with actual usvg parsing.
|
||||||
|
Ok(DocumentContent::Vector(VectorModel {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
view_box: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
Some(DocumentFormat::Raster) => {
|
||||||
|
// TODO: Replace with actual image-crate loading.
|
||||||
|
Ok(DocumentContent::Raster(RasterModel {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
dimensions: (1920, 1080),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
None => Err(Error::Unsupported(format!(
|
||||||
|
"extension: {}",
|
||||||
|
path.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.unwrap_or("(none)")
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scans a directory for supported files and populates the session.
|
||||||
|
pub(crate) fn scan_directory_into(session: &mut SessionData, dir: &Path) {
|
||||||
|
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||||
|
let mut paths: Vec<_> = entries
|
||||||
|
.filter_map(std::result::Result::ok)
|
||||||
|
.map(|e| e.path())
|
||||||
|
.filter(|p| p.is_file() && detect_format(p).is_some())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
paths.sort();
|
||||||
|
|
||||||
|
session.items = paths.into_iter().map(|p| SessionItem::new(p, 0)).collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
418
src/document/manager.rs
Normal file
418
src/document/manager.rs
Normal file
|
|
@ -0,0 +1,418 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// src/document/manager.rs
|
||||||
|
//
|
||||||
|
// Document manager: session state, cursors, clipboard, and command dispatch.
|
||||||
|
|
||||||
|
use crate::document::loader::{load_document, scan_directory_into};
|
||||||
|
use crate::document::session::command::SessionCommand;
|
||||||
|
use crate::document::session::data::{CollectionKind, SessionData, SessionItem};
|
||||||
|
use crate::document::session::store;
|
||||||
|
use crate::document::DocumentContent;
|
||||||
|
use crate::error::Error;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// The load state of the currently viewed document.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum DocumentState {
|
||||||
|
Empty,
|
||||||
|
Loading,
|
||||||
|
Loaded(DocumentContent),
|
||||||
|
Error(Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Central state for all sessions, cursors, and the document clipboard.
|
||||||
|
///
|
||||||
|
/// Two cursors are maintained:
|
||||||
|
/// - `active_session_index` – the currently active session
|
||||||
|
/// - `active_document_index` – the currently active document within that session
|
||||||
|
///
|
||||||
|
/// All `SessionCommand` variants that do not carry an explicit target operate
|
||||||
|
/// on the active cursor. `SelectSession` and `SelectDocument` set them.
|
||||||
|
pub struct DocumentManager {
|
||||||
|
sessions: Vec<SessionData>,
|
||||||
|
active_session_index: Option<usize>,
|
||||||
|
active_document_index: Option<usize>,
|
||||||
|
clipboard: Option<SessionItem>,
|
||||||
|
session_file_path: Option<PathBuf>,
|
||||||
|
pub state: DocumentState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DocumentManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
sessions: Vec::new(),
|
||||||
|
active_session_index: None,
|
||||||
|
active_document_index: None,
|
||||||
|
clipboard: None,
|
||||||
|
session_file_path: None,
|
||||||
|
state: DocumentState::Empty,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DocumentManager {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single public entry point for all session and document state changes.
|
||||||
|
pub fn handle(&mut self, command: SessionCommand) {
|
||||||
|
match command {
|
||||||
|
// Session lifecycle
|
||||||
|
SessionCommand::NewSession { name } => self.new_session(&name),
|
||||||
|
SessionCommand::OpenSession { path } => self.open_session_file(&path),
|
||||||
|
SessionCommand::DeleteSession => self.delete_session(),
|
||||||
|
SessionCommand::SaveSession => self.save_session(),
|
||||||
|
SessionCommand::SelectSession { name } => self.select_session(&name),
|
||||||
|
SessionCommand::DuplicateSession { new_name } => self.duplicate_session(&new_name),
|
||||||
|
SessionCommand::RenameSession { name } => self.rename_session(&name),
|
||||||
|
|
||||||
|
// Collection management
|
||||||
|
SessionCommand::AddDirectoryToSession { dir } => {
|
||||||
|
if let Some(session) = self.active_session_mut() {
|
||||||
|
scan_directory_into(session, &dir);
|
||||||
|
}
|
||||||
|
self.reset_document_cursor();
|
||||||
|
}
|
||||||
|
SessionCommand::AddDocumentToSession { path } => self.add_document(&path),
|
||||||
|
|
||||||
|
// Document management
|
||||||
|
SessionCommand::SelectDocument { index } => self.select_document(index),
|
||||||
|
SessionCommand::CopyDocument => self.copy_document(),
|
||||||
|
SessionCommand::PasteDocument => self.paste_document(),
|
||||||
|
SessionCommand::RemoveDocument => self.remove_document(),
|
||||||
|
SessionCommand::NextDocument => self.navigate_next(),
|
||||||
|
SessionCommand::PreviousDocument => self.navigate_previous(),
|
||||||
|
SessionCommand::MoveDocumentUp => self.move_document_up(),
|
||||||
|
SessionCommand::MoveDocumentDown => self.move_document_down(),
|
||||||
|
SessionCommand::MoveDocumentTo { index } => self.move_document_to(index),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only access to the active session.
|
||||||
|
#[must_use]
|
||||||
|
pub fn active_session(&self) -> Option<&SessionData> {
|
||||||
|
self.active_session_index.map(|i| &self.sessions[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The index of the currently active document within the active session.
|
||||||
|
#[must_use]
|
||||||
|
pub fn active_document_index(&self) -> Option<usize> {
|
||||||
|
self.active_document_index
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only access to all sessions.
|
||||||
|
#[must_use]
|
||||||
|
pub fn sessions(&self) -> &[SessionData] {
|
||||||
|
&self.sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only access to a session by name.
|
||||||
|
#[must_use]
|
||||||
|
pub fn session_by_name(&self, name: &str) -> Option<&SessionData> {
|
||||||
|
self.sessions.iter().find(|s| s.name == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Private helpers --
|
||||||
|
|
||||||
|
fn active_session_mut(&mut self) -> Option<&mut SessionData> {
|
||||||
|
self.active_session_index.map(|i| &mut self.sessions[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_by_name(&self, name: &str) -> Option<usize> {
|
||||||
|
self.sessions.iter().position(|s| s.name == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets `active_document_index` to `Some(0)` if the active session has items, else `None`.
|
||||||
|
fn reset_document_cursor(&mut self) {
|
||||||
|
self.active_document_index = match self.active_session() {
|
||||||
|
Some(s) if !s.items.is_empty() => Some(0),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_session(&mut self, name: &str) {
|
||||||
|
if self.index_by_name(name).is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.sessions.push(SessionData::new(
|
||||||
|
name,
|
||||||
|
CollectionKind::DocumentCollection,
|
||||||
|
None,
|
||||||
|
Vec::new(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_session_file(&mut self, path: &Path) {
|
||||||
|
match store::load_sessions(path) {
|
||||||
|
Ok(mut loaded) => {
|
||||||
|
for session in &mut loaded {
|
||||||
|
session.items.retain(|item| {
|
||||||
|
if item.path.exists() {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
log::error!(
|
||||||
|
"Session '{}': {}",
|
||||||
|
session.name,
|
||||||
|
Error::NotFound(item.path.display().to_string())
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let browser_index = loaded
|
||||||
|
.iter()
|
||||||
|
.position(|s| s.kind == CollectionKind::DirectoryBrowser);
|
||||||
|
|
||||||
|
self.sessions = loaded;
|
||||||
|
self.active_session_index = browser_index;
|
||||||
|
self.session_file_path = Some(path.to_path_buf());
|
||||||
|
self.state = DocumentState::Empty;
|
||||||
|
self.reset_document_cursor();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.state = DocumentState::Error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We have no 'Close session' it wouldbe only removes it from runtime, keeps it in .ron file
|
||||||
|
// For actual deletion, use delete_session()
|
||||||
|
// So 'close_session' from ui view is save_session and close session view.
|
||||||
|
|
||||||
|
fn delete_session(&mut self) {
|
||||||
|
let Some(idx) = self.active_session_index else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The DirectoryBrowser session is managed by the UI and cannot be deleted.
|
||||||
|
if self.sessions[idx].kind == CollectionKind::DirectoryBrowser {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.sessions.remove(idx);
|
||||||
|
self.active_document_index = None;
|
||||||
|
|
||||||
|
self.active_session_index = if self.sessions.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(idx.saturating_sub(1))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_session(&mut self) {
|
||||||
|
let Some(ref path) = self.session_file_path else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Err(e) = store::save_sessions(&path, &self.sessions) {
|
||||||
|
self.state = DocumentState::Error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_session(&mut self, name: &str) {
|
||||||
|
if let Some(idx) = self.index_by_name(name) {
|
||||||
|
self.active_session_index = Some(idx);
|
||||||
|
self.reset_document_cursor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duplicate_session(&mut self, new_name: &str) {
|
||||||
|
if self.index_by_name(new_name).is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(idx) = self.active_session_index else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let items = self.sessions[idx].items.clone();
|
||||||
|
self.sessions.push(SessionData::new(
|
||||||
|
new_name,
|
||||||
|
CollectionKind::DocumentCollection,
|
||||||
|
None,
|
||||||
|
items,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rename_session(&mut self, name: &str) {
|
||||||
|
let Some(idx) = self.active_session_index else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The DirectoryBrowser session cannot be renamed.
|
||||||
|
if self.sessions[idx].kind == CollectionKind::DirectoryBrowser {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.index_by_name(name).is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.sessions[idx].name = name.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_document(&mut self, path: &Path) {
|
||||||
|
self.state = DocumentState::Loading;
|
||||||
|
match load_document(path) {
|
||||||
|
Ok(content) => {
|
||||||
|
if let Some(session) = self.active_session_mut() {
|
||||||
|
if session.kind == CollectionKind::DocumentCollection
|
||||||
|
&& !session.items.iter().any(|i| i.path == path)
|
||||||
|
{
|
||||||
|
session.items.push(SessionItem::new(path.to_path_buf(), 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.active_document_index = self
|
||||||
|
.active_session()
|
||||||
|
.and_then(|s| s.items.iter().position(|i| i.path == path));
|
||||||
|
self.state = DocumentState::Loaded(content);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.state = DocumentState::Error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_document(&mut self, index: usize) {
|
||||||
|
if let Some(session) = self.active_session() {
|
||||||
|
if index < session.items.len() {
|
||||||
|
self.active_document_index = Some(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_document(&mut self) {
|
||||||
|
let Some(idx) = self.active_document_index else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.clipboard = self
|
||||||
|
.active_session()
|
||||||
|
.and_then(|s| s.items.get(idx))
|
||||||
|
.cloned();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paste_document(&mut self) {
|
||||||
|
let Some(item) = self.clipboard.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !matches!(
|
||||||
|
self.active_session().map(|s| &s.kind),
|
||||||
|
Some(CollectionKind::DocumentCollection)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_index = {
|
||||||
|
let Some(session) = self.active_session_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if session
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.any(|i| i.path == item.path && i.page_index == item.page_index)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.items.push(item);
|
||||||
|
session.items.len() - 1
|
||||||
|
};
|
||||||
|
|
||||||
|
self.active_document_index = Some(new_index);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_document(&mut self) {
|
||||||
|
if !matches!(
|
||||||
|
self.active_session().map(|s| &s.kind),
|
||||||
|
Some(CollectionKind::DocumentCollection)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(idx) = self.active_document_index else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_index = {
|
||||||
|
let Some(session) = self.active_session_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if idx >= session.items.len() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.items.remove(idx);
|
||||||
|
let new_len = session.items.len();
|
||||||
|
if new_len == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(idx.min(new_len - 1))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.active_document_index = new_index;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn navigate_next(&mut self) {
|
||||||
|
let len = self.active_session().map_or(0, |s| s.items.len());
|
||||||
|
if let Some(idx) = self.active_document_index {
|
||||||
|
if idx + 1 < len {
|
||||||
|
self.active_document_index = Some(idx + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn navigate_previous(&mut self) {
|
||||||
|
if let Some(idx) = self.active_document_index {
|
||||||
|
if idx > 0 {
|
||||||
|
self.active_document_index = Some(idx - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_document_up(&mut self) {
|
||||||
|
let Some(idx) = self.active_document_index else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if idx == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(session) = self.active_session_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
session.items.swap(idx, idx - 1);
|
||||||
|
self.active_document_index = Some(idx - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_document_down(&mut self) {
|
||||||
|
let Some(idx) = self.active_document_index else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(session) = self.active_session_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if idx + 1 >= session.items.len() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.items.swap(idx, idx + 1);
|
||||||
|
self.active_document_index = Some(idx + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_document_to(&mut self, target: usize) {
|
||||||
|
let Some(idx) = self.active_document_index else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(session) = self.active_session_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if idx == target || target >= session.items.len() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let item = session.items.remove(idx);
|
||||||
|
session.items.insert(target, item);
|
||||||
|
self.active_document_index = Some(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/document/mod.rs
Normal file
24
src/document/mod.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// src/document/mod.rs
|
||||||
|
//
|
||||||
|
// Document abstraction – the public surface of the document subsystem.
|
||||||
|
|
||||||
|
pub mod command;
|
||||||
|
pub mod loader;
|
||||||
|
pub mod manager;
|
||||||
|
pub mod model;
|
||||||
|
pub mod session;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
use self::model::{PortableModel, RasterModel, VectorModel};
|
||||||
|
|
||||||
|
/// The decoded content of a loaded document.
|
||||||
|
///
|
||||||
|
/// This enum is internal to Noctua. The UI only ever sees a `RawImage`
|
||||||
|
/// produced by the render subsystem – it never matches on this type.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum DocumentContent {
|
||||||
|
Raster(RasterModel),
|
||||||
|
Vector(VectorModel),
|
||||||
|
Portable(PortableModel),
|
||||||
|
}
|
||||||
12
src/document/model/mod.rs
Normal file
12
src/document/model/mod.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// src/document/model/mod.rs
|
||||||
|
//
|
||||||
|
// The three concrete document models Noctua understands.
|
||||||
|
|
||||||
|
pub mod portable;
|
||||||
|
pub mod raster;
|
||||||
|
pub mod vector;
|
||||||
|
|
||||||
|
pub use portable::PortableModel;
|
||||||
|
pub use raster::RasterModel;
|
||||||
|
pub use vector::VectorModel;
|
||||||
14
src/document/model/portable.rs
Normal file
14
src/document/model/portable.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// src/document/model/portable.rs
|
||||||
|
//
|
||||||
|
// Portable document format model (PDF).
|
||||||
|
|
||||||
|
use crate::document::types::PageLayout;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct PortableModel {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub page_count: usize,
|
||||||
|
pub layout: PageLayout,
|
||||||
|
}
|
||||||
12
src/document/model/raster.rs
Normal file
12
src/document/model/raster.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// src/document/model/raster.rs
|
||||||
|
//
|
||||||
|
// Raster image document model (JPEG, PNG, etc.).
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct RasterModel {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub dimensions: (u32, u32),
|
||||||
|
}
|
||||||
13
src/document/model/vector.rs
Normal file
13
src/document/model/vector.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// src/document/model/vector.rs
|
||||||
|
//
|
||||||
|
// Vector image document model (SVG).
|
||||||
|
|
||||||
|
use crate::document::types::ViewBox;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct VectorModel {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub view_box: Option<ViewBox>,
|
||||||
|
}
|
||||||
66
src/document/session/command.rs
Normal file
66
src/document/session/command.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// src/document/session/command.rs
|
||||||
|
//
|
||||||
|
// Commands that operate on sessions and their document collections.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Commands operating on sessions and their document collections.
|
||||||
|
///
|
||||||
|
/// Two implicit cursors exist in the manager:
|
||||||
|
/// - `active_session_index` – the currently active session
|
||||||
|
/// - `active_document_index` – the currently active document within that session
|
||||||
|
///
|
||||||
|
/// `SelectSession` and `SelectDocument` set these cursors. All commands that
|
||||||
|
/// do not carry an explicit target operate on the active cursor.
|
||||||
|
///
|
||||||
|
/// Only `DocumentCollection` sessions are user-managed. The single
|
||||||
|
/// `DirectoryBrowser` session is created by the UI and cannot be closed,
|
||||||
|
/// renamed, or removed.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum SessionCommand {
|
||||||
|
// Session lifecycle
|
||||||
|
/// Create a new empty `DocumentCollection` session.
|
||||||
|
NewSession { name: String },
|
||||||
|
/// Load sessions from an existing .ron file.
|
||||||
|
OpenSession { path: PathBuf },
|
||||||
|
/// Persist all sessions to the .ron file.
|
||||||
|
SaveSession,
|
||||||
|
/// Set the active session cursor by name.
|
||||||
|
SelectSession { name: String },
|
||||||
|
/// Duplicate the active session as a new `DocumentCollection`.
|
||||||
|
///
|
||||||
|
/// When the source is a `DirectoryBrowser` this produces a snapshot:
|
||||||
|
/// the current items are copied into a new `DocumentCollection`.
|
||||||
|
DuplicateSession { new_name: String },
|
||||||
|
/// Delete the active `DocumentCollection` session permanently (removed from .ron on save).
|
||||||
|
DeleteSession,
|
||||||
|
/// Rename the active `DocumentCollection` session.
|
||||||
|
RenameSession { name: String },
|
||||||
|
|
||||||
|
// Collection management within the active session
|
||||||
|
/// Scan a directory and populate the active `DirectoryBrowser` session.
|
||||||
|
AddDirectoryToSession { dir: PathBuf },
|
||||||
|
/// Add a single document to the active `DocumentCollection` session.
|
||||||
|
AddDocumentToSession { path: PathBuf },
|
||||||
|
|
||||||
|
// Document management within the active session
|
||||||
|
/// Set the active document cursor by index.
|
||||||
|
SelectDocument { index: usize },
|
||||||
|
/// Copy the active document into the clipboard.
|
||||||
|
CopyDocument,
|
||||||
|
/// Paste the clipboard item at the end of the active session.
|
||||||
|
PasteDocument,
|
||||||
|
/// Remove the active document from the active session.
|
||||||
|
RemoveDocument,
|
||||||
|
/// Move the active document cursor to the next item.
|
||||||
|
NextDocument,
|
||||||
|
/// Move the active document cursor to the previous item.
|
||||||
|
PreviousDocument,
|
||||||
|
/// Move the active document one position towards the front.
|
||||||
|
MoveDocumentUp,
|
||||||
|
/// Move the active document one position towards the back.
|
||||||
|
MoveDocumentDown,
|
||||||
|
/// Move the active document to an explicit position.
|
||||||
|
MoveDocumentTo { index: usize },
|
||||||
|
}
|
||||||
72
src/document/session/data.rs
Normal file
72
src/document/session/data.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// src/document/session/data.rs
|
||||||
|
//
|
||||||
|
// Pure data structures for sessions and their document items.
|
||||||
|
//
|
||||||
|
// Operational state (active session cursor, active document cursor, clipboard)
|
||||||
|
// lives in the manager, not here. This module contains only what is persisted.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Determines how a session's items are managed.
|
||||||
|
///
|
||||||
|
/// A `DirectoryBrowser` session is created once by the UI and cannot be closed
|
||||||
|
/// or removed by the user. It can be duplicated via `DuplicateSession`, which
|
||||||
|
/// produces a new `DocumentCollection` containing a snapshot of the current items.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum CollectionKind {
|
||||||
|
/// Items are derived from a directory scan; `path` holds the source directory.
|
||||||
|
DirectoryBrowser,
|
||||||
|
/// Items are a curated list of documents or pages; `path` is None.
|
||||||
|
DocumentCollection,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single entry in a session – a file path and an optional page within that file.
|
||||||
|
///
|
||||||
|
/// `page_index` is 0 for single-page formats (raster, SVG) and non-zero for
|
||||||
|
/// multi-page documents (PDF, multi-frame TIFF) where each page is its own item.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SessionItem {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub page_index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionItem {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(path: PathBuf, page_index: usize) -> Self {
|
||||||
|
Self { path, page_index }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The persistable state of a single session.
|
||||||
|
///
|
||||||
|
/// Operational state (active document index, clipboard) lives in the manager.
|
||||||
|
/// This struct is serialized as-is to the RON session file.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SessionData {
|
||||||
|
pub name: String,
|
||||||
|
pub kind: CollectionKind,
|
||||||
|
/// For `DirectoryBrowser`: the source directory to scan.
|
||||||
|
/// For `DocumentCollection`: `None` (items are managed individually).
|
||||||
|
#[serde(default)]
|
||||||
|
pub path: Option<PathBuf>,
|
||||||
|
pub items: Vec<SessionItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionData {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
name: &str,
|
||||||
|
kind: CollectionKind,
|
||||||
|
path: Option<PathBuf>,
|
||||||
|
items: Vec<SessionItem>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.to_string(),
|
||||||
|
kind,
|
||||||
|
path,
|
||||||
|
items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/document/session/mod.rs
Normal file
8
src/document/session/mod.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// src/document/session/mod.rs
|
||||||
|
//
|
||||||
|
// Session subsystem: data model, commands, and persistence.
|
||||||
|
|
||||||
|
pub mod command;
|
||||||
|
pub mod data;
|
||||||
|
pub mod store;
|
||||||
62
src/document/session/store.rs
Normal file
62
src/document/session/store.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// src/document/session/store.rs
|
||||||
|
//
|
||||||
|
// RON-based persistence for session data: load and save session lists.
|
||||||
|
//
|
||||||
|
// For the session concept and the meaning of each field see `session.rs`.
|
||||||
|
//
|
||||||
|
// On disk a session file contains a RON list of SessionData records:
|
||||||
|
//
|
||||||
|
// [
|
||||||
|
// (
|
||||||
|
// name: "Pictures",
|
||||||
|
// kind: DirectoryBrowser,
|
||||||
|
// path: Some("/home/user/Pictures"),
|
||||||
|
// items: [],
|
||||||
|
// current_index: None,
|
||||||
|
// ),
|
||||||
|
// (
|
||||||
|
// name: "Project Docs",
|
||||||
|
// kind: DocumentCollection,
|
||||||
|
// path: None,
|
||||||
|
// items: [
|
||||||
|
// (path: "/home/user/docs/spec.pdf", page_index: 0),
|
||||||
|
// (path: "/home/user/docs/notes.pdf", page_index: 2),
|
||||||
|
// ],
|
||||||
|
// current_index: Some(0),
|
||||||
|
// ),
|
||||||
|
// ]
|
||||||
|
|
||||||
|
use crate::document::session::data::SessionData;
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Loads all sessions from a RON file.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`Error::Io`] if the file cannot be read.
|
||||||
|
/// Returns [`Error::Session`] if the file cannot be parsed.
|
||||||
|
pub fn load_sessions(path: &Path) -> Result<Vec<SessionData>> {
|
||||||
|
let content = std::fs::read_to_string(path).map_err(Error::Io)?;
|
||||||
|
|
||||||
|
ron::from_str(&content).map_err(|e| {
|
||||||
|
Error::Session(format!(
|
||||||
|
"Failed to parse session file {}: {e}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves all sessions to a RON file.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`Error::Session`] if the sessions cannot be serialized.
|
||||||
|
/// Returns [`Error::Io`] if the file cannot be written.
|
||||||
|
pub fn save_sessions(path: &Path, sessions: &[SessionData]) -> Result<()> {
|
||||||
|
let content = ron::ser::to_string_pretty(sessions, ron::ser::PrettyConfig::default())
|
||||||
|
.map_err(|e| Error::Session(format!("Failed to serialize sessions: {e}")))?;
|
||||||
|
|
||||||
|
std::fs::write(path, content).map_err(Error::Io)
|
||||||
|
}
|
||||||
17
src/document/types/image.rs
Normal file
17
src/document/types/image.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// src/document/types/image.rs
|
||||||
|
//
|
||||||
|
// Raw pixel buffer passed from the render subsystem to the UI.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// A decoded, format-independent pixel buffer ready for display.
|
||||||
|
///
|
||||||
|
/// The UI receives this type from the `DocumentManager` and never needs to
|
||||||
|
/// know which document format (PDF, SVG, raster) produced it.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct RawImage {
|
||||||
|
pub data: Arc<Vec<u8>>,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
45
src/document/types/layout.rs
Normal file
45
src/document/types/layout.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// src/document/types/layout.rs
|
||||||
|
//
|
||||||
|
// PageLayout – describes the vertical arrangement of pages in a document.
|
||||||
|
|
||||||
|
/// The computed layout of all pages in a multi-page document.
|
||||||
|
///
|
||||||
|
/// Stores absolute y-offsets for each page so the render subsystem can
|
||||||
|
/// determine which pages are visible at a given scroll position without
|
||||||
|
/// iterating the full list on every frame.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct PageLayout {
|
||||||
|
pub page_starts: Vec<f64>,
|
||||||
|
pub page_sizes: Vec<(f64, f64)>,
|
||||||
|
pub gap: f64,
|
||||||
|
pub total_height: f64,
|
||||||
|
pub max_width: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PageLayout {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(page_sizes: Vec<(f64, f64)>, gap: f64) -> Self {
|
||||||
|
let mut page_starts = Vec::with_capacity(page_sizes.len());
|
||||||
|
let mut y = 0.0_f64;
|
||||||
|
let mut max_width = 0.0_f64;
|
||||||
|
|
||||||
|
for &(w, h) in &page_sizes {
|
||||||
|
page_starts.push(y);
|
||||||
|
y += h + gap;
|
||||||
|
if w > max_width {
|
||||||
|
max_width = w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_height = if page_sizes.is_empty() { 0.0 } else { y - gap };
|
||||||
|
|
||||||
|
Self {
|
||||||
|
page_starts,
|
||||||
|
page_sizes,
|
||||||
|
gap,
|
||||||
|
total_height,
|
||||||
|
max_width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/document/types/mod.rs
Normal file
11
src/document/types/mod.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// src/document/types/mod.rs
|
||||||
|
//
|
||||||
|
// Shared domain primitives used across the document and render subsystems.
|
||||||
|
|
||||||
|
pub mod image;
|
||||||
|
pub mod layout;
|
||||||
|
pub mod viewbox;
|
||||||
|
|
||||||
|
pub use layout::PageLayout;
|
||||||
|
pub use viewbox::ViewBox;
|
||||||
16
src/document/types/viewbox.rs
Normal file
16
src/document/types/viewbox.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// src/document/types/viewbox.rs
|
||||||
|
//
|
||||||
|
// ViewBox – describes the visible coordinate space of a vector document.
|
||||||
|
|
||||||
|
/// The coordinate space declared by an SVG `viewBox` attribute.
|
||||||
|
///
|
||||||
|
/// Used by the render subsystem to map document coordinates to screen pixels
|
||||||
|
/// without exposing SVG internals to the UI.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ViewBox {
|
||||||
|
pub x: f64,
|
||||||
|
pub y: f64,
|
||||||
|
pub width: f64,
|
||||||
|
pub height: f64,
|
||||||
|
}
|
||||||
|
|
@ -1,277 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/domain/document/collection.rs
|
|
||||||
//
|
|
||||||
// Document collection for managing multiple documents.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use crate::domain::document::core::content::DocumentContent;
|
|
||||||
|
|
||||||
/// A collection of documents with navigation support.
|
|
||||||
///
|
|
||||||
/// This abstraction is useful for:
|
|
||||||
/// - Browsing through folders of images
|
|
||||||
/// - Batch operations on multiple documents
|
|
||||||
/// - Comparison views (showing multiple documents side-by-side)
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct DocumentCollection {
|
|
||||||
/// List of document paths in the collection.
|
|
||||||
paths: Vec<PathBuf>,
|
|
||||||
/// Currently active document index.
|
|
||||||
current_index: Option<usize>,
|
|
||||||
/// Currently loaded document (lazy-loaded).
|
|
||||||
current_document: Option<DocumentContent>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DocumentCollection {
|
|
||||||
/// Create an empty collection.
|
|
||||||
#[must_use]
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
paths: Vec::new(),
|
|
||||||
current_index: None,
|
|
||||||
current_document: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a collection from a list of paths.
|
|
||||||
#[must_use]
|
|
||||||
pub fn from_paths(paths: Vec<PathBuf>) -> Self {
|
|
||||||
let current_index = if paths.is_empty() { None } else { Some(0) };
|
|
||||||
|
|
||||||
Self {
|
|
||||||
paths,
|
|
||||||
current_index,
|
|
||||||
current_document: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the number of documents in the collection.
|
|
||||||
#[must_use]
|
|
||||||
pub fn len(&self) -> usize {
|
|
||||||
self.paths.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if the collection is empty.
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.paths.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current document index (0-based).
|
|
||||||
#[must_use]
|
|
||||||
pub fn current_index(&self) -> Option<usize> {
|
|
||||||
self.current_index
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current document path.
|
|
||||||
#[must_use]
|
|
||||||
pub fn current_path(&self) -> Option<&PathBuf> {
|
|
||||||
self.current_index.and_then(|idx| self.paths.get(idx))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all paths in the collection.
|
|
||||||
#[must_use]
|
|
||||||
pub fn paths(&self) -> &[PathBuf] {
|
|
||||||
&self.paths
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a reference to the currently loaded document.
|
|
||||||
#[must_use]
|
|
||||||
pub fn current_document(&self) -> Option<&DocumentContent> {
|
|
||||||
self.current_document.as_ref()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a mutable reference to the currently loaded document.
|
|
||||||
#[must_use]
|
|
||||||
pub fn current_document_mut(&mut self) -> Option<&mut DocumentContent> {
|
|
||||||
self.current_document.as_mut()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the currently loaded document.
|
|
||||||
pub fn set_current_document(&mut self, document: DocumentContent) {
|
|
||||||
self.current_document = Some(document);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear the currently loaded document.
|
|
||||||
pub fn clear_current_document(&mut self) {
|
|
||||||
self.current_document = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Navigate to the next document in the collection.
|
|
||||||
///
|
|
||||||
/// Returns the new index if successful, None if already at the end.
|
|
||||||
pub fn next(&mut self) -> Option<usize> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Navigate to the previous document in the collection.
|
|
||||||
///
|
|
||||||
/// Returns the new index if successful, None if already at the start.
|
|
||||||
pub fn previous(&mut self) -> Option<usize> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Navigate to a specific index.
|
|
||||||
///
|
|
||||||
/// Returns true if the index is valid and navigation succeeded.
|
|
||||||
pub fn goto(&mut self, index: usize) -> bool {
|
|
||||||
if index < self.paths.len() {
|
|
||||||
self.current_index = Some(index);
|
|
||||||
self.current_document = None; // Clear document (needs reload)
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a document path to the collection.
|
|
||||||
pub fn add_path(&mut self, path: PathBuf) {
|
|
||||||
self.paths.push(path);
|
|
||||||
if self.current_index.is_none() {
|
|
||||||
self.current_index = Some(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove a document path at the given index.
|
|
||||||
///
|
|
||||||
/// Returns the removed path if successful.
|
|
||||||
pub fn remove_at(&mut self, index: usize) -> Option<PathBuf> {
|
|
||||||
if index < self.paths.len() {
|
|
||||||
let removed = self.paths.remove(index);
|
|
||||||
|
|
||||||
// Update current index if needed
|
|
||||||
if let Some(current) = self.current_index {
|
|
||||||
if current == index {
|
|
||||||
// Removed current document
|
|
||||||
self.current_document = None;
|
|
||||||
if self.paths.is_empty() {
|
|
||||||
self.current_index = None;
|
|
||||||
} else if current >= self.paths.len() {
|
|
||||||
self.current_index = Some(self.paths.len() - 1);
|
|
||||||
}
|
|
||||||
} else if current > index {
|
|
||||||
// Adjust index after removal
|
|
||||||
self.current_index = Some(current - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(removed)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear the entire collection.
|
|
||||||
pub fn clear(&mut self) {
|
|
||||||
self.paths.clear();
|
|
||||||
self.current_index = None;
|
|
||||||
self.current_document = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if there is a next document available.
|
|
||||||
#[must_use]
|
|
||||||
pub fn has_next(&self) -> bool {
|
|
||||||
if let Some(current) = self.current_index {
|
|
||||||
current + 1 < self.paths.len()
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if there is a previous document available.
|
|
||||||
#[must_use]
|
|
||||||
pub fn has_previous(&self) -> bool {
|
|
||||||
if let Some(current) = self.current_index {
|
|
||||||
current > 0
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the path at a specific index.
|
|
||||||
#[must_use]
|
|
||||||
pub fn path_at(&self, index: usize) -> Option<&PathBuf> {
|
|
||||||
self.paths.get(index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for DocumentCollection {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_collection() {
|
|
||||||
let collection = DocumentCollection::new();
|
|
||||||
assert!(collection.is_empty());
|
|
||||||
assert_eq!(collection.len(), 0);
|
|
||||||
assert_eq!(collection.current_index(), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_navigation() {
|
|
||||||
let paths = vec![
|
|
||||||
PathBuf::from("a.png"),
|
|
||||||
PathBuf::from("b.png"),
|
|
||||||
PathBuf::from("c.png"),
|
|
||||||
];
|
|
||||||
let mut collection = DocumentCollection::from_paths(paths);
|
|
||||||
|
|
||||||
assert_eq!(collection.current_index(), Some(0));
|
|
||||||
assert_eq!(collection.next(), Some(1));
|
|
||||||
assert_eq!(collection.next(), Some(2));
|
|
||||||
assert_eq!(collection.next(), None); // At end
|
|
||||||
assert_eq!(collection.previous(), Some(1));
|
|
||||||
assert_eq!(collection.previous(), Some(0));
|
|
||||||
assert_eq!(collection.previous(), None); // At start
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_goto() {
|
|
||||||
let paths = vec![
|
|
||||||
PathBuf::from("a.png"),
|
|
||||||
PathBuf::from("b.png"),
|
|
||||||
PathBuf::from("c.png"),
|
|
||||||
];
|
|
||||||
let mut collection = DocumentCollection::from_paths(paths);
|
|
||||||
|
|
||||||
assert!(collection.goto(2));
|
|
||||||
assert_eq!(collection.current_index(), Some(2));
|
|
||||||
assert!(!collection.goto(10)); // Invalid index
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_remove() {
|
|
||||||
let paths = vec![
|
|
||||||
PathBuf::from("a.png"),
|
|
||||||
PathBuf::from("b.png"),
|
|
||||||
PathBuf::from("c.png"),
|
|
||||||
];
|
|
||||||
let mut collection = DocumentCollection::from_paths(paths);
|
|
||||||
|
|
||||||
collection.goto(1);
|
|
||||||
assert_eq!(collection.remove_at(1), Some(PathBuf::from("b.png")));
|
|
||||||
assert_eq!(collection.len(), 2);
|
|
||||||
assert_eq!(collection.current_index(), Some(1)); // Now points to c.png
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,403 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/domain/document/core/content.rs
|
|
||||||
//
|
|
||||||
// Type-erased document content enum.
|
|
||||||
|
|
||||||
use std::fmt;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use cosmic::iced_renderer::graphics::image::image_rs::ImageFormat as CosmicImageFormat;
|
|
||||||
use cosmic::widget::image::Handle as ImageHandle;
|
|
||||||
|
|
||||||
use super::document::{
|
|
||||||
DocResult, DocumentInfo, FlipDirection, InterpolationQuality, MultiPage, MultiPageThumbnails,
|
|
||||||
RenderOutput, Renderable, Rotation, RotationMode, Transformable, TransformState,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::domain::document::types::raster::RasterDocument;
|
|
||||||
#[cfg(feature = "vector")]
|
|
||||||
use crate::domain::document::types::vector::VectorDocument;
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
use crate::domain::document::types::portable::PortableDocument;
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Document Kind
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Supported document kinds (for format detection).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum DocumentKind {
|
|
||||||
Raster,
|
|
||||||
Vector,
|
|
||||||
Portable,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DocumentKind {
|
|
||||||
/// Detect document kind from file path.
|
|
||||||
#[must_use]
|
|
||||||
pub fn from_path(path: &Path) -> Option<Self> {
|
|
||||||
let ext = path.extension()?.to_str()?.to_lowercase();
|
|
||||||
|
|
||||||
// SVG
|
|
||||||
#[cfg(feature = "vector")]
|
|
||||||
if ext == "svg" || ext == "svgz" {
|
|
||||||
return Some(Self::Vector);
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
if ext == "pdf" {
|
|
||||||
return Some(Self::Portable);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Raster: Check via cosmic/image-rs
|
|
||||||
if CosmicImageFormat::from_path(path).is_ok() {
|
|
||||||
return Some(Self::Raster);
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for DocumentKind {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Raster => write!(f, "Raster"),
|
|
||||||
Self::Vector => write!(f, "Vector"),
|
|
||||||
Self::Portable => write!(f, "Portable"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Document Content Enum
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Type-erased document content.
|
|
||||||
///
|
|
||||||
/// The application only holds one document at a time, so the size difference
|
|
||||||
/// between variants is acceptable. Boxing would add unnecessary indirection.
|
|
||||||
#[allow(clippy::large_enum_variant)]
|
|
||||||
pub enum DocumentContent {
|
|
||||||
Raster(RasterDocument),
|
|
||||||
#[cfg(feature = "vector")]
|
|
||||||
Vector(VectorDocument),
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
Portable(PortableDocument),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Debug for DocumentContent {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Raster(_) => write!(f, "DocumentContent::Raster(...)"),
|
|
||||||
#[cfg(feature = "vector")]
|
|
||||||
Self::Vector(_) => write!(f, "DocumentContent::Vector(...)"),
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
Self::Portable(_) => write!(f, "DocumentContent::Portable(...)"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Trait Implementations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
impl Renderable for DocumentContent {
|
|
||||||
fn render(&mut self, scale: f64) -> DocResult<RenderOutput> {
|
|
||||||
match self {
|
|
||||||
Self::Raster(doc) => doc.render(scale),
|
|
||||||
#[cfg(feature = "vector")]
|
|
||||||
Self::Vector(doc) => doc.render(scale),
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
Self::Portable(doc) => doc.render(scale),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn info(&self) -> DocumentInfo {
|
|
||||||
match self {
|
|
||||||
Self::Raster(doc) => doc.info(),
|
|
||||||
#[cfg(feature = "vector")]
|
|
||||||
Self::Vector(doc) => doc.info(),
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
Self::Portable(doc) => doc.info(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Transformable for DocumentContent {
|
|
||||||
fn rotate(&mut self, rotation: Rotation) {
|
|
||||||
match self {
|
|
||||||
Self::Raster(doc) => doc.rotate(rotation),
|
|
||||||
#[cfg(feature = "vector")]
|
|
||||||
Self::Vector(doc) => doc.rotate(rotation),
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
Self::Portable(doc) => doc.rotate(rotation),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flip(&mut self, direction: FlipDirection) {
|
|
||||||
match self {
|
|
||||||
Self::Raster(doc) => doc.flip(direction),
|
|
||||||
#[cfg(feature = "vector")]
|
|
||||||
Self::Vector(doc) => doc.flip(direction),
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
Self::Portable(doc) => doc.flip(direction),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn transform_state(&self) -> TransformState {
|
|
||||||
match self {
|
|
||||||
Self::Raster(doc) => doc.transform_state(),
|
|
||||||
#[cfg(feature = "vector")]
|
|
||||||
Self::Vector(doc) => doc.transform_state(),
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
Self::Portable(doc) => doc.transform_state(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rotate_fine(&mut self, angle_degrees: f32) {
|
|
||||||
match self {
|
|
||||||
Self::Raster(doc) => doc.rotate_fine(angle_degrees),
|
|
||||||
#[cfg(feature = "vector")]
|
|
||||||
Self::Vector(doc) => doc.rotate_fine(angle_degrees),
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
Self::Portable(doc) => doc.rotate_fine(angle_degrees),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reset_fine_rotation(&mut self) {
|
|
||||||
match self {
|
|
||||||
Self::Raster(doc) => doc.reset_fine_rotation(),
|
|
||||||
#[cfg(feature = "vector")]
|
|
||||||
Self::Vector(doc) => doc.reset_fine_rotation(),
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
Self::Portable(doc) => doc.reset_fine_rotation(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_interpolation_quality(&mut self, quality: InterpolationQuality) {
|
|
||||||
match self {
|
|
||||||
Self::Raster(doc) => doc.set_interpolation_quality(quality),
|
|
||||||
#[cfg(feature = "vector")]
|
|
||||||
Self::Vector(doc) => doc.set_interpolation_quality(quality),
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
Self::Portable(doc) => doc.set_interpolation_quality(quality),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Convenience Methods
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
impl DocumentContent {
|
|
||||||
/// Rotate document 90 degrees clockwise.
|
|
||||||
pub fn rotate_cw(&mut self) {
|
|
||||||
let new_rotation_mode = self.transform_state().rotation.rotate_cw();
|
|
||||||
match new_rotation_mode {
|
|
||||||
RotationMode::Standard(rot) => self.rotate(rot),
|
|
||||||
RotationMode::Fine(deg) => {
|
|
||||||
let normalized = ((deg / 90.0).round() as i16 * 90) % 360;
|
|
||||||
let rot = match normalized {
|
|
||||||
0 => Rotation::None,
|
|
||||||
90 => Rotation::Cw90,
|
|
||||||
180 => Rotation::Cw180,
|
|
||||||
270 => Rotation::Cw270,
|
|
||||||
_ => Rotation::None,
|
|
||||||
};
|
|
||||||
self.rotate(rot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rotate document 90 degrees counter-clockwise.
|
|
||||||
pub fn rotate_ccw(&mut self) {
|
|
||||||
let new_rotation_mode = self.transform_state().rotation.rotate_ccw();
|
|
||||||
match new_rotation_mode {
|
|
||||||
RotationMode::Standard(rot) => self.rotate(rot),
|
|
||||||
RotationMode::Fine(deg) => {
|
|
||||||
let normalized = ((deg / 90.0).round() as i16 * 90 + 360) % 360;
|
|
||||||
let rot = match normalized {
|
|
||||||
0 => Rotation::None,
|
|
||||||
90 => Rotation::Cw90,
|
|
||||||
180 => Rotation::Cw180,
|
|
||||||
270 => Rotation::Cw270,
|
|
||||||
_ => Rotation::None,
|
|
||||||
};
|
|
||||||
self.rotate(rot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Flip document horizontally.
|
|
||||||
pub fn flip_horizontal(&mut self) {
|
|
||||||
self.flip(FlipDirection::Horizontal);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Flip document vertically.
|
|
||||||
pub fn flip_vertical(&mut self) {
|
|
||||||
self.flip(FlipDirection::Vertical);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the document kind.
|
|
||||||
#[must_use]
|
|
||||||
pub fn kind(&self) -> DocumentKind {
|
|
||||||
match self {
|
|
||||||
Self::Raster(_) => DocumentKind::Raster,
|
|
||||||
#[cfg(feature = "vector")]
|
|
||||||
Self::Vector(_) => DocumentKind::Vector,
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
Self::Portable(_) => DocumentKind::Portable,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if document supports multiple pages.
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_multi_page(&self) -> bool {
|
|
||||||
matches!(self, Self::Portable(_))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get total page count (returns 1 for single-page documents).
|
|
||||||
#[must_use]
|
|
||||||
pub fn page_count(&self) -> usize {
|
|
||||||
match self {
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
Self::Portable(doc) => doc.page_count(),
|
|
||||||
_ => 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get current page index (0 for single-page documents).
|
|
||||||
#[must_use]
|
|
||||||
pub fn current_page(&self) -> usize {
|
|
||||||
match self {
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
Self::Portable(doc) => doc.current_page(),
|
|
||||||
_ => 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Navigate to a specific page (no-op for single-page documents).
|
|
||||||
pub fn go_to_page(&mut self, page: usize) -> DocResult<()> {
|
|
||||||
match self {
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
Self::Portable(doc) => doc.go_to_page(page),
|
|
||||||
_ => Ok(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get thumbnail for a specific page (mutable access for trait compatibility).
|
|
||||||
pub fn get_thumbnail(&mut self, page: usize) -> DocResult<Option<ImageHandle>> {
|
|
||||||
match self {
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
Self::Portable(doc) => doc.get_thumbnail(page),
|
|
||||||
_ => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<ImageHandle> {
|
|
||||||
match self {
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
Self::Portable(doc) => doc.get_thumbnail_handle(page),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if thumbnails are ready to be generated.
|
|
||||||
#[must_use]
|
|
||||||
pub fn thumbnails_ready(&self) -> bool {
|
|
||||||
match self {
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
Self::Portable(doc) => doc.thumbnails_ready(),
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get count of thumbnails currently loaded.
|
|
||||||
#[must_use]
|
|
||||||
pub fn thumbnails_loaded(&self) -> usize {
|
|
||||||
match self {
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
Self::Portable(doc) => PortableDocument::thumbnails_loaded(doc),
|
|
||||||
_ => 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if all thumbnails have been loaded (trait-compliant).
|
|
||||||
#[must_use]
|
|
||||||
pub fn all_thumbnails_loaded(&self) -> bool {
|
|
||||||
match self {
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
Self::Portable(doc) => MultiPageThumbnails::thumbnails_loaded(doc),
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate thumbnail for a specific page.
|
|
||||||
pub fn generate_thumbnail_page(&mut self, page: usize) -> DocResult<()> {
|
|
||||||
match self {
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
Self::Portable(doc) => MultiPageThumbnails::generate_thumbnail_page(doc, page),
|
|
||||||
_ => Ok(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate all thumbnails.
|
|
||||||
pub fn generate_thumbnails(&mut self) -> DocResult<()> {
|
|
||||||
match self {
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
Self::Portable(doc) => MultiPageThumbnails::generate_all_thumbnails(doc),
|
|
||||||
_ => Ok(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current rendered image handle.
|
|
||||||
#[must_use]
|
|
||||||
pub fn handle(&self) -> Option<ImageHandle> {
|
|
||||||
match self {
|
|
||||||
Self::Raster(doc) => Some(doc.handle()),
|
|
||||||
#[cfg(feature = "vector")]
|
|
||||||
Self::Vector(doc) => Some(doc.handle()),
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
Self::Portable(doc) => Some(doc.handle()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get current dimensions after transformations.
|
|
||||||
#[must_use]
|
|
||||||
pub fn dimensions(&self) -> (u32, u32) {
|
|
||||||
match self {
|
|
||||||
Self::Raster(doc) => doc.dimensions(),
|
|
||||||
#[cfg(feature = "vector")]
|
|
||||||
Self::Vector(doc) => doc.dimensions(),
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
Self::Portable(doc) => doc.dimensions(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Crop the document (supported for all types - works on rendered output).
|
|
||||||
pub fn crop(&mut self, x: u32, y: u32, width: u32, height: u32) -> DocResult<()> {
|
|
||||||
match self {
|
|
||||||
Self::Raster(doc) => doc.crop(x, y, width, height).map_err(|e| anyhow::anyhow!(e)),
|
|
||||||
#[cfg(feature = "vector")]
|
|
||||||
Self::Vector(doc) => doc.crop(x, y, width, height).map_err(|e| anyhow::anyhow!(e)),
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
Self::Portable(doc) => doc.crop(x, y, width, height).map_err(|e| anyhow::anyhow!(e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract document metadata (basic info and EXIF if available).
|
|
||||||
#[must_use]
|
|
||||||
pub fn extract_meta(&self, path: &Path) -> crate::domain::document::core::metadata::DocumentMeta {
|
|
||||||
match self {
|
|
||||||
Self::Raster(doc) => doc.extract_meta(path),
|
|
||||||
#[cfg(feature = "vector")]
|
|
||||||
Self::Vector(doc) => doc.extract_meta(path),
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
Self::Portable(doc) => doc.extract_meta(path),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,250 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/domain/document/core/document.rs
|
|
||||||
//
|
|
||||||
// Core document traits and abstractions.
|
|
||||||
|
|
||||||
use cosmic::widget::image::Handle as ImageHandle;
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Type Definitions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Result type alias for document operations.
|
|
||||||
pub type DocResult<T> = anyhow::Result<T>;
|
|
||||||
|
|
||||||
/// Rotation state for documents.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub enum Rotation {
|
|
||||||
/// No rotation (0 degrees).
|
|
||||||
#[default]
|
|
||||||
None,
|
|
||||||
/// 90 degrees clockwise.
|
|
||||||
Cw90,
|
|
||||||
/// 180 degrees.
|
|
||||||
Cw180,
|
|
||||||
/// 270 degrees clockwise (90 counter-clockwise).
|
|
||||||
Cw270,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Rotation {
|
|
||||||
/// Rotate clockwise by 90 degrees.
|
|
||||||
#[must_use]
|
|
||||||
pub fn rotate_cw(self) -> Self {
|
|
||||||
match self {
|
|
||||||
Self::None => Self::Cw90,
|
|
||||||
Self::Cw90 => Self::Cw180,
|
|
||||||
Self::Cw180 => Self::Cw270,
|
|
||||||
Self::Cw270 => Self::None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rotate counter-clockwise by 90 degrees.
|
|
||||||
#[must_use]
|
|
||||||
pub fn rotate_ccw(self) -> Self {
|
|
||||||
match self {
|
|
||||||
Self::None => Self::Cw270,
|
|
||||||
Self::Cw270 => Self::Cw180,
|
|
||||||
Self::Cw180 => Self::Cw90,
|
|
||||||
Self::Cw90 => Self::None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert to degrees (0, 90, 180, 270).
|
|
||||||
#[must_use]
|
|
||||||
pub fn to_degrees(self) -> i16 {
|
|
||||||
match self {
|
|
||||||
Self::None => 0,
|
|
||||||
Self::Cw90 => 90,
|
|
||||||
Self::Cw180 => 180,
|
|
||||||
Self::Cw270 => 270,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rotation mode: standard 90° steps or fine-grained rotation.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
||||||
pub enum RotationMode {
|
|
||||||
/// Standard 90° rotation (lossless for most formats).
|
|
||||||
Standard(Rotation),
|
|
||||||
/// Fine-grained rotation in degrees (0.0 - 360.0) with interpolation.
|
|
||||||
Fine(f32),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for RotationMode {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Standard(Rotation::None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RotationMode {
|
|
||||||
/// Convert rotation to degrees (0.0 - 360.0).
|
|
||||||
#[must_use]
|
|
||||||
pub fn to_degrees(self) -> f32 {
|
|
||||||
match self {
|
|
||||||
Self::Standard(r) => f32::from(r.to_degrees()),
|
|
||||||
Self::Fine(deg) => deg,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if rotation is a multiple of 90 degrees.
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_multiple_of_90(self) -> bool {
|
|
||||||
match self {
|
|
||||||
Self::Standard(_) => true,
|
|
||||||
Self::Fine(deg) => (deg % 90.0).abs() < 0.01,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if no rotation is applied.
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_none(self) -> bool {
|
|
||||||
match self {
|
|
||||||
Self::Standard(Rotation::None) => true,
|
|
||||||
Self::Standard(_) => false,
|
|
||||||
Self::Fine(deg) => deg.abs() < 0.01,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rotate clockwise by 90 degrees.
|
|
||||||
#[must_use]
|
|
||||||
pub fn rotate_cw(self) -> Self {
|
|
||||||
match self {
|
|
||||||
Self::Standard(r) => Self::Standard(r.rotate_cw()),
|
|
||||||
Self::Fine(deg) => Self::Fine((deg + 90.0) % 360.0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rotate counter-clockwise by 90 degrees.
|
|
||||||
#[must_use]
|
|
||||||
pub fn rotate_ccw(self) -> Self {
|
|
||||||
match self {
|
|
||||||
Self::Standard(r) => Self::Standard(r.rotate_ccw()),
|
|
||||||
Self::Fine(deg) => Self::Fine((deg - 90.0 + 360.0) % 360.0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Interpolation quality for fine rotation and resizing operations.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub enum InterpolationQuality {
|
|
||||||
/// Fast, nearest neighbor interpolation.
|
|
||||||
Fast,
|
|
||||||
/// Balanced bilinear interpolation (default).
|
|
||||||
#[default]
|
|
||||||
Balanced,
|
|
||||||
/// Best quality, bicubic interpolation.
|
|
||||||
Best,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Flip direction for documents.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum FlipDirection {
|
|
||||||
/// Flip along the vertical axis (mirror left-right).
|
|
||||||
Horizontal,
|
|
||||||
/// Flip along the horizontal axis (mirror top-bottom).
|
|
||||||
Vertical,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Current transformation state of a document.
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
|
||||||
pub struct TransformState {
|
|
||||||
/// Current rotation mode (standard 90° or fine rotation).
|
|
||||||
pub rotation: RotationMode,
|
|
||||||
/// Whether flipped horizontally.
|
|
||||||
pub flip_h: bool,
|
|
||||||
/// Whether flipped vertically.
|
|
||||||
pub flip_v: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Output of a render operation.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct RenderOutput {
|
|
||||||
/// Image handle for display.
|
|
||||||
pub handle: ImageHandle,
|
|
||||||
/// Rendered width in pixels.
|
|
||||||
pub width: u32,
|
|
||||||
/// Rendered height in pixels.
|
|
||||||
pub height: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Document metadata/information.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct DocumentInfo {
|
|
||||||
/// Native width in pixels (before transforms).
|
|
||||||
pub width: u32,
|
|
||||||
/// Native height in pixels (before transforms).
|
|
||||||
pub height: u32,
|
|
||||||
/// Document format description.
|
|
||||||
pub format: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Traits
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Trait for documents that can be rendered to an image.
|
|
||||||
pub trait Renderable {
|
|
||||||
/// Render the document at the given scale factor.
|
|
||||||
fn render(&mut self, scale: f64) -> DocResult<RenderOutput>;
|
|
||||||
|
|
||||||
/// Get document information (dimensions, format).
|
|
||||||
fn info(&self) -> DocumentInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trait for documents that support geometric transformations.
|
|
||||||
pub trait Transformable {
|
|
||||||
/// Apply a standard 90° rotation.
|
|
||||||
fn rotate(&mut self, rotation: Rotation);
|
|
||||||
|
|
||||||
/// Flip in the given direction.
|
|
||||||
fn flip(&mut self, direction: FlipDirection);
|
|
||||||
|
|
||||||
/// Get the current transformation state.
|
|
||||||
fn transform_state(&self) -> TransformState;
|
|
||||||
|
|
||||||
/// Apply fine-grained rotation in degrees (0.0 - 360.0).
|
|
||||||
fn rotate_fine(&mut self, _angle_degrees: f32) {
|
|
||||||
// Default: no-op (not all formats support fine rotation)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reset any accumulated fine rotation.
|
|
||||||
fn reset_fine_rotation(&mut self) {
|
|
||||||
// Default: no-op
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set interpolation quality for transformations.
|
|
||||||
fn set_interpolation_quality(&mut self, _quality: InterpolationQuality) {
|
|
||||||
// Default: no-op
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trait for documents with multiple pages.
|
|
||||||
pub trait MultiPage {
|
|
||||||
/// Get total number of pages.
|
|
||||||
fn page_count(&self) -> usize;
|
|
||||||
|
|
||||||
/// Get current page index (0-based).
|
|
||||||
fn current_page(&self) -> usize;
|
|
||||||
|
|
||||||
/// Navigate to a specific page.
|
|
||||||
fn go_to_page(&mut self, page: usize) -> DocResult<()>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trait for multi-page documents that support thumbnail generation.
|
|
||||||
pub trait MultiPageThumbnails: MultiPage {
|
|
||||||
/// Get thumbnail for a specific page.
|
|
||||||
fn get_thumbnail(&mut self, page: usize) -> DocResult<Option<ImageHandle>>;
|
|
||||||
|
|
||||||
/// Check if thumbnails are ready to be generated.
|
|
||||||
fn thumbnails_ready(&self) -> bool;
|
|
||||||
|
|
||||||
/// Check if all thumbnails have been loaded.
|
|
||||||
fn thumbnails_loaded(&self) -> bool;
|
|
||||||
|
|
||||||
/// Generate thumbnail for a specific page.
|
|
||||||
fn generate_thumbnail_page(&mut self, page: usize) -> DocResult<()>;
|
|
||||||
|
|
||||||
/// Generate all thumbnails.
|
|
||||||
fn generate_all_thumbnails(&mut self) -> DocResult<()>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,197 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/domain/document/core/metadata.rs
|
|
||||||
//
|
|
||||||
// Document metadata structures and EXIF parsing.
|
|
||||||
|
|
||||||
use std::io::Cursor;
|
|
||||||
|
|
||||||
/// Minutes per degree for GPS coordinate conversion (DMS to decimal degrees).
|
|
||||||
const MINUTES_PER_DEGREE: f64 = 60.0;
|
|
||||||
|
|
||||||
/// Seconds per degree for GPS coordinate conversion (DMS to decimal degrees).
|
|
||||||
const SECONDS_PER_DEGREE: f64 = 3600.0;
|
|
||||||
|
|
||||||
/// Basic document metadata (always available).
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct BasicMeta {
|
|
||||||
/// File name (without path).
|
|
||||||
pub file_name: String,
|
|
||||||
/// Full file path.
|
|
||||||
pub file_path: String,
|
|
||||||
/// Image format as string (e.g., "PNG", "JPEG", "PDF").
|
|
||||||
pub format: String,
|
|
||||||
/// Width in pixels.
|
|
||||||
pub width: u32,
|
|
||||||
/// Height in pixels.
|
|
||||||
pub height: u32,
|
|
||||||
/// File size in bytes.
|
|
||||||
pub file_size: u64,
|
|
||||||
/// Color type description (e.g., "RGBA8", "RGB8", "Grayscale").
|
|
||||||
pub color_type: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BasicMeta {
|
|
||||||
/// Format file size as human-readable string.
|
|
||||||
pub fn file_size_display(&self) -> String {
|
|
||||||
const KB: u64 = 1024;
|
|
||||||
const MB: u64 = KB * 1024;
|
|
||||||
const GB: u64 = MB * 1024;
|
|
||||||
|
|
||||||
#[allow(clippy::cast_precision_loss)]
|
|
||||||
if self.file_size >= GB {
|
|
||||||
let size_gb = self.file_size as f64 / GB as f64;
|
|
||||||
format!("{size_gb:.2} GB")
|
|
||||||
} else if self.file_size >= MB {
|
|
||||||
let size_mb = self.file_size as f64 / MB as f64;
|
|
||||||
format!("{size_mb:.2} MB")
|
|
||||||
} else if self.file_size >= KB {
|
|
||||||
let size_kb = self.file_size as f64 / KB as f64;
|
|
||||||
format!("{size_kb:.1} KB")
|
|
||||||
} else {
|
|
||||||
let size = self.file_size;
|
|
||||||
format!("{size} B")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format resolution as "W × H".
|
|
||||||
pub fn resolution_display(&self) -> String {
|
|
||||||
format!("{} × {}", self.width, self.height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// EXIF metadata (optional, mainly for JPEG/TIFF).
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct ExifMeta {
|
|
||||||
pub camera_make: Option<String>,
|
|
||||||
pub camera_model: Option<String>,
|
|
||||||
pub date_time: Option<String>,
|
|
||||||
pub exposure_time: Option<String>,
|
|
||||||
pub f_number: Option<String>,
|
|
||||||
pub iso: Option<u32>,
|
|
||||||
pub focal_length: Option<String>,
|
|
||||||
pub gps_latitude: Option<f64>,
|
|
||||||
pub gps_longitude: Option<f64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ExifMeta {
|
|
||||||
/// Parse EXIF data from raw image bytes.
|
|
||||||
///
|
|
||||||
/// Extracts camera information, exposure settings, and GPS coordinates
|
|
||||||
/// from JPEG/TIFF EXIF metadata using the kamadak-exif crate.
|
|
||||||
pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
|
|
||||||
use exif::{In, Reader, Tag};
|
|
||||||
|
|
||||||
let cursor = Cursor::new(bytes);
|
|
||||||
let exif_reader = Reader::new();
|
|
||||||
let exif = exif_reader.read_from_container(&mut cursor.clone()).ok()?;
|
|
||||||
|
|
||||||
let mut meta = Self::default();
|
|
||||||
|
|
||||||
// Camera make and model
|
|
||||||
if let Some(field) = exif.get_field(Tag::Make, In::PRIMARY) {
|
|
||||||
meta.camera_make = Some(field.display_value().to_string().trim().to_string());
|
|
||||||
}
|
|
||||||
if let Some(field) = exif.get_field(Tag::Model, In::PRIMARY) {
|
|
||||||
meta.camera_model = Some(field.display_value().to_string().trim().to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Date and time
|
|
||||||
if let Some(field) = exif.get_field(Tag::DateTime, In::PRIMARY) {
|
|
||||||
meta.date_time = Some(field.display_value().to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exposure time
|
|
||||||
if let Some(field) = exif.get_field(Tag::ExposureTime, In::PRIMARY) {
|
|
||||||
meta.exposure_time = Some(field.display_value().to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// F-number (aperture)
|
|
||||||
if let Some(field) = exif.get_field(Tag::FNumber, In::PRIMARY) {
|
|
||||||
meta.f_number = Some(field.display_value().to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ISO speed
|
|
||||||
if let Some(field) = exif.get_field(Tag::PhotographicSensitivity, In::PRIMARY) {
|
|
||||||
if let exif::Value::Short(ref vec) = field.value {
|
|
||||||
if let Some(&iso) = vec.first() {
|
|
||||||
meta.iso = Some(u32::from(iso));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Focal length
|
|
||||||
if let Some(field) = exif.get_field(Tag::FocalLength, In::PRIMARY) {
|
|
||||||
meta.focal_length = Some(field.display_value().to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// GPS coordinates
|
|
||||||
meta.gps_latitude = Self::parse_gps_coord(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef);
|
|
||||||
meta.gps_longitude = Self::parse_gps_coord(&exif, Tag::GPSLongitude, Tag::GPSLongitudeRef);
|
|
||||||
|
|
||||||
Some(meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse GPS coordinate from EXIF data (converts DMS to decimal degrees).
|
|
||||||
fn parse_gps_coord(exif: &exif::Exif, coord_tag: exif::Tag, ref_tag: exif::Tag) -> Option<f64> {
|
|
||||||
use exif::{In, Value};
|
|
||||||
|
|
||||||
let coord_field = exif.get_field(coord_tag, In::PRIMARY)?;
|
|
||||||
let ref_field = exif.get_field(ref_tag, In::PRIMARY)?;
|
|
||||||
|
|
||||||
// Get reference (N/S for latitude, E/W for longitude)
|
|
||||||
let reference = ref_field.display_value().to_string();
|
|
||||||
|
|
||||||
// Parse DMS (Degrees, Minutes, Seconds) values
|
|
||||||
if let Value::Rational(ref rationals) = coord_field.value {
|
|
||||||
if rationals.len() >= 3 {
|
|
||||||
let degrees = rationals[0].to_f64();
|
|
||||||
let minutes = rationals[1].to_f64();
|
|
||||||
let seconds = rationals[2].to_f64();
|
|
||||||
|
|
||||||
// Convert to decimal degrees
|
|
||||||
let mut decimal =
|
|
||||||
degrees + (minutes / MINUTES_PER_DEGREE) + (seconds / SECONDS_PER_DEGREE);
|
|
||||||
|
|
||||||
// Apply sign based on hemisphere
|
|
||||||
if reference == "S" || reference == "W" {
|
|
||||||
decimal = -decimal;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Some(decimal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Combined camera make and model for display.
|
|
||||||
pub fn camera_display(&self) -> Option<String> {
|
|
||||||
match (&self.camera_make, &self.camera_model) {
|
|
||||||
(Some(make), Some(model)) => {
|
|
||||||
if model.starts_with(make) {
|
|
||||||
Some(model.clone())
|
|
||||||
} else {
|
|
||||||
Some(format!("{make} {model}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(Some(make), None) => Some(make.clone()),
|
|
||||||
(None, Some(model)) => Some(model.clone()),
|
|
||||||
(None, None) => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format GPS coordinates for display.
|
|
||||||
pub fn gps_display(&self) -> Option<String> {
|
|
||||||
match (self.gps_latitude, self.gps_longitude) {
|
|
||||||
(Some(lat), Some(lon)) => Some(format!("{lat:.5}, {lon:.5}")),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Complete document metadata container.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct DocumentMeta {
|
|
||||||
pub basic: BasicMeta,
|
|
||||||
pub exif: Option<ExifMeta>,
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/domain/document/core/mod.rs
|
|
||||||
//
|
|
||||||
// Core document abstractions: traits, types, and metadata.
|
|
||||||
|
|
||||||
pub mod content;
|
|
||||||
pub mod document;
|
|
||||||
pub mod metadata;
|
|
||||||
pub mod page;
|
|
||||||
|
|
||||||
// Re-export commonly used types
|
|
||||||
pub use content::DocumentContent;
|
|
||||||
pub use metadata::DocumentMeta;
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/domain/document/core/page.rs
|
|
||||||
//
|
|
||||||
// Page abstraction for multi-page documents.
|
|
||||||
|
|
||||||
use cosmic::widget::image::Handle as ImageHandle;
|
|
||||||
|
|
||||||
/// Represents a single page in a multi-page document.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct Page {
|
|
||||||
/// Page index (0-based).
|
|
||||||
pub index: usize,
|
|
||||||
/// Page width in pixels.
|
|
||||||
pub width: u32,
|
|
||||||
/// Page height in pixels.
|
|
||||||
pub height: u32,
|
|
||||||
/// Optional thumbnail handle.
|
|
||||||
pub thumbnail: Option<ImageHandle>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Page {
|
|
||||||
/// Create a new page.
|
|
||||||
#[must_use]
|
|
||||||
pub fn new(index: usize, width: u32, height: u32) -> Self {
|
|
||||||
Self {
|
|
||||||
index,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
thumbnail: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a page with a thumbnail.
|
|
||||||
#[must_use]
|
|
||||||
pub fn with_thumbnail(index: usize, width: u32, height: u32, thumbnail: ImageHandle) -> Self {
|
|
||||||
Self {
|
|
||||||
index,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
thumbnail: Some(thumbnail),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the thumbnail for this page.
|
|
||||||
pub fn set_thumbnail(&mut self, thumbnail: ImageHandle) {
|
|
||||||
self.thumbnail = Some(thumbnail);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if this page has a thumbnail.
|
|
||||||
#[must_use]
|
|
||||||
pub fn has_thumbnail(&self) -> bool {
|
|
||||||
self.thumbnail.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the aspect ratio of the page.
|
|
||||||
#[must_use]
|
|
||||||
pub fn aspect_ratio(&self) -> f32 {
|
|
||||||
if self.height == 0 {
|
|
||||||
1.0
|
|
||||||
} else {
|
|
||||||
#[allow(clippy::cast_precision_loss)]
|
|
||||||
{
|
|
||||||
self.width as f32 / self.height as f32
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get page dimensions as a tuple.
|
|
||||||
#[must_use]
|
|
||||||
pub fn dimensions(&self) -> (u32, u32) {
|
|
||||||
(self.width, self.height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/domain/document/mod.rs
|
|
||||||
//
|
|
||||||
// Document domain: core abstractions, types, and operations.
|
|
||||||
|
|
||||||
pub mod collection;
|
|
||||||
pub mod core;
|
|
||||||
pub mod operations;
|
|
||||||
pub mod types;
|
|
||||||
|
|
||||||
// Re-export core abstractions (only used ones)
|
|
||||||
#[allow(unused_imports)]
|
|
||||||
pub use core::{DocumentContent, DocumentMeta};
|
|
||||||
|
|
||||||
// Note: 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.
|
|
||||||
|
|
@ -1,281 +0,0 @@
|
||||||
# Document Operations
|
|
||||||
|
|
||||||
This module provides transformation, rendering, and export operations for documents.
|
|
||||||
|
|
||||||
## Architecture: Two-Level Operations
|
|
||||||
|
|
||||||
The operations module is designed with **two distinct levels** of abstraction:
|
|
||||||
|
|
||||||
### 1. Low-Level Operations (Internal/Private)
|
|
||||||
|
|
||||||
**Purpose:** Direct manipulation of pixel data for raster images.
|
|
||||||
|
|
||||||
**Visibility:** `pub(crate)` - Internal to the crate only.
|
|
||||||
|
|
||||||
**Location:** `transform.rs` (internal helpers)
|
|
||||||
|
|
||||||
**Functions:**
|
|
||||||
- `apply_rotation(img, rotation)` - Rotate raster pixels
|
|
||||||
- `apply_flip(img, direction)` - Flip raster pixels
|
|
||||||
- `crop_to_image(img, x, y, w, h)` - Crop raster to image
|
|
||||||
|
|
||||||
**When to use:**
|
|
||||||
- ONLY in document type implementations (RasterDocument, VectorDocument, PortableDocument)
|
|
||||||
- NOT accessible outside the crate
|
|
||||||
- NOT for application or UI code
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```rust
|
|
||||||
// INTERNAL USE ONLY - in document type implementations
|
|
||||||
impl Transformable for RasterDocument {
|
|
||||||
fn rotate(&mut self, rotation: Rotation) {
|
|
||||||
// Low-level operation used internally
|
|
||||||
self.image = apply_rotation(self.image, rotation);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. High-Level Operations (Type-Agnostic)
|
|
||||||
|
|
||||||
**Purpose:** Document transformations that work across **all** document types (Raster, Vector, Portable).
|
|
||||||
|
|
||||||
**Location:** `transform.rs` (high-level section)
|
|
||||||
|
|
||||||
**Functions:**
|
|
||||||
- `rotate_document_cw(document)` - Rotate any document 90° CW
|
|
||||||
- `rotate_document_ccw(document)` - Rotate any document 90° CCW
|
|
||||||
- `flip_document_horizontal(document)` - Flip any document horizontally
|
|
||||||
- `flip_document_vertical(document)` - Flip any document vertically
|
|
||||||
- `rotate_document_to(document, rotation)` - Rotate to specific angle
|
|
||||||
- `reset_document_transforms(document)` - Reset all transformations
|
|
||||||
|
|
||||||
**When to use:**
|
|
||||||
- In application commands (`TransformDocumentCommand`)
|
|
||||||
- In UI message handlers
|
|
||||||
- Anywhere you work with `DocumentContent` (type-erased document)
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```rust
|
|
||||||
use crate::domain::document::operations::transform;
|
|
||||||
|
|
||||||
// RECOMMENDED: Use high-level operations
|
|
||||||
let mut document = DocumentContent::Raster(raster_doc);
|
|
||||||
transform::rotate_document_cw(&mut document)?;
|
|
||||||
transform::flip_document_horizontal(&mut document)?;
|
|
||||||
|
|
||||||
// Works with Vector and Portable too!
|
|
||||||
let mut svg = DocumentContent::Vector(vector_doc);
|
|
||||||
transform::rotate_document_cw(&mut svg)?; // Lossless viewport transform
|
|
||||||
|
|
||||||
// Works with PDF!
|
|
||||||
let mut pdf = DocumentContent::Portable(portable_doc);
|
|
||||||
transform::rotate_document_cw(&mut pdf)?; // Backend handles rendering
|
|
||||||
```
|
|
||||||
|
|
||||||
## Why This Separation?
|
|
||||||
|
|
||||||
### Why Low-Level Operations Are Internal
|
|
||||||
|
|
||||||
**Problem:** Exposing low-level operations creates confusion:
|
|
||||||
- Developers don't know whether to use `apply_rotation()` or `rotate_document_cw()`
|
|
||||||
- Low-level operations only work on `DynamicImage`, not `DocumentContent`
|
|
||||||
- Creates two ways to do the same thing (violates DRY)
|
|
||||||
|
|
||||||
**Solution:** Make them `pub(crate)`:
|
|
||||||
```rust
|
|
||||||
// NOT POSSIBLE - apply_rotation is internal
|
|
||||||
transform::apply_rotation(img, Rotation::Cw90); // Compile error!
|
|
||||||
|
|
||||||
// USE THIS - high-level operation
|
|
||||||
transform::rotate_document_cw(&mut document)?; // Works!
|
|
||||||
```
|
|
||||||
|
|
||||||
### Why High-Level Operations Exist
|
|
||||||
|
|
||||||
**Problem without them:**
|
|
||||||
```rust
|
|
||||||
// Coupled to implementation details
|
|
||||||
match document {
|
|
||||||
DocumentContent::Raster(ref mut doc) => doc.rotate(Rotation::Cw90),
|
|
||||||
DocumentContent::Vector(ref mut doc) => doc.rotate(Rotation::Cw90),
|
|
||||||
DocumentContent::Portable(ref mut doc) => doc.rotate(Rotation::Cw90),
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
```rust
|
|
||||||
// Single API for all types
|
|
||||||
transform::rotate_document_cw(&mut document)?;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Benefits
|
|
||||||
|
|
||||||
1. **Single Source of Truth**
|
|
||||||
- Rotation logic (handling RotationMode::Fine, etc.) is in ONE place
|
|
||||||
- No duplication across UI handlers, commands, and tests
|
|
||||||
|
|
||||||
2. **Type Safety**
|
|
||||||
- Works through `DocumentContent` abstraction
|
|
||||||
- Compiler ensures all document types implement required traits
|
|
||||||
|
|
||||||
3. **Future-Proof**
|
|
||||||
- Adding new document types (DJVU, EPUB) doesn't require updating call sites
|
|
||||||
- Operations automatically work with new types
|
|
||||||
|
|
||||||
4. **Testable**
|
|
||||||
- High-level operations can be tested independently
|
|
||||||
- No UI dependencies
|
|
||||||
|
|
||||||
## Implementation Details
|
|
||||||
|
|
||||||
### How It Works
|
|
||||||
|
|
||||||
High-level operations use the `Transformable` trait:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub fn rotate_document_cw(document: &mut DocumentContent) -> DocResult<()> {
|
|
||||||
let new_rotation_mode = document.transform_state().rotation.rotate_cw();
|
|
||||||
|
|
||||||
match new_rotation_mode {
|
|
||||||
RotationMode::Standard(rot) => document.rotate(rot),
|
|
||||||
RotationMode::Fine(deg) => {
|
|
||||||
// Convert fine rotation to nearest 90° standard rotation
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This delegates to the document type's implementation:
|
|
||||||
|
|
||||||
- **Raster:** Actual pixel rotation via `imageops::rotate90()`
|
|
||||||
- **Vector:** Viewport matrix transformation (lossless!)
|
|
||||||
- **Portable:** View rotation, rendered by backend (Poppler)
|
|
||||||
|
|
||||||
### Each Type Transforms Differently
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// Raster: Pixel manipulation (lossy for fine rotations)
|
|
||||||
impl Transformable for RasterDocument {
|
|
||||||
fn rotate(&mut self, rotation: Rotation) {
|
|
||||||
self.image = apply_rotation(self.image, rotation);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vector: Viewport transform (always lossless!)
|
|
||||||
impl Transformable for VectorDocument {
|
|
||||||
fn rotate(&mut self, rotation: Rotation) {
|
|
||||||
self.transform_matrix = self.transform_matrix.rotate(rotation.to_degrees());
|
|
||||||
// No rasterization needed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Portable: View rotation (backend handles rendering)
|
|
||||||
impl Transformable for PortableDocument {
|
|
||||||
fn rotate(&mut self, rotation: Rotation) {
|
|
||||||
self.view_rotation = (self.view_rotation + rotation.to_degrees()) % 360;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage Guidelines
|
|
||||||
|
|
||||||
### Prefer High-Level Operations
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// In application commands
|
|
||||||
pub fn execute(&self, manager: &mut DocumentManager) -> DocResult<()> {
|
|
||||||
let document = manager.current_document_mut()?;
|
|
||||||
transform::rotate_document_cw(document)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// In UI message handlers
|
|
||||||
AppMessage::RotateCW => {
|
|
||||||
if let Some(doc) = &mut self.model.document {
|
|
||||||
transform::rotate_document_cw(doc)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Don't Use Low-Level Operations in Application/UI Code
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// COMPILE ERROR - Low-level operations are pub(crate)
|
|
||||||
let pixels = transform::apply_rotation(img, Rotation::Cw90); // Won't compile!
|
|
||||||
|
|
||||||
// CORRECT - Use high-level operations
|
|
||||||
transform::rotate_document_cw(&mut document)?;
|
|
||||||
```
|
|
||||||
|
|
||||||
### ℹ️ Low-Level Operations in Document Implementations
|
|
||||||
|
|
||||||
Low-level operations are only accessible within document type implementations:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// INTERNAL ONLY - in domain/document/types/raster.rs
|
|
||||||
impl Transformable for RasterDocument {
|
|
||||||
fn rotate(&mut self, rotation: Rotation) {
|
|
||||||
// This works because we're inside the crate
|
|
||||||
self.image = apply_rotation(self.image, rotation);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Module Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
operations/
|
|
||||||
├── mod.rs # Public API exports
|
|
||||||
├── transform.rs # Low-level + High-level transforms
|
|
||||||
├── render.rs # Rendering utilities (scale, fit, etc.)
|
|
||||||
├── export.rs # Export to various formats
|
|
||||||
└── README.md # This file
|
|
||||||
```
|
|
||||||
|
|
||||||
## Adding New Operations
|
|
||||||
|
|
||||||
When adding a new operation:
|
|
||||||
|
|
||||||
1. **Add low-level function** (if pixel manipulation is needed) - mark as `pub(crate)`
|
|
||||||
2. **Add high-level function** that works on `DocumentContent` - mark as `pub`
|
|
||||||
3. **Export high-level function only** from `mod.rs`
|
|
||||||
4. **Update domain exports** in `domain/document/mod.rs`
|
|
||||||
5. **Create command** in `application/commands/`
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// 1. Low-level (internal only) - in transform.rs
|
|
||||||
pub(crate) fn apply_grayscale(img: DynamicImage) -> DynamicImage { ... }
|
|
||||||
|
|
||||||
// 2. High-level (public API) - in transform.rs
|
|
||||||
pub fn grayscale_document(document: &mut DocumentContent) -> DocResult<()> {
|
|
||||||
// Delegates to Transformable trait or uses low-level helper
|
|
||||||
...
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Export high-level only - in operations/mod.rs
|
|
||||||
pub use transform::{grayscale_document}; // NOT apply_grayscale!
|
|
||||||
|
|
||||||
// 4. Export from domain - in document/mod.rs
|
|
||||||
pub use operations::{grayscale_document};
|
|
||||||
|
|
||||||
// 5. Command - in application/commands/
|
|
||||||
pub struct GrayscaleCommand;
|
|
||||||
impl GrayscaleCommand {
|
|
||||||
pub fn execute(&self, manager: &mut DocumentManager) -> DocResult<()> {
|
|
||||||
let doc = manager.current_document_mut()?;
|
|
||||||
transform::grayscale_document(doc) // High-level operation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Related Concepts
|
|
||||||
|
|
||||||
- **Traits:** `Renderable`, `Transformable`, `MultiPage` (in `domain/document/core/document.rs`)
|
|
||||||
- **Type Erasure:** `DocumentContent` enum (in `domain/document/core/content.rs`)
|
|
||||||
- **Commands:** Application layer operations (in `application/commands/`)
|
|
||||||
- **Domain Layer:** Pure business logic, no UI dependencies
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/domain/document/operations/crop.rs
|
|
||||||
//
|
|
||||||
// Crop operation domain model.
|
|
||||||
|
|
||||||
/// Crop region in pixel coordinates.
|
|
||||||
///
|
|
||||||
/// Pure domain model - represents a rectangular region to crop.
|
|
||||||
/// No UI concerns, just data.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct CropRegion {
|
|
||||||
pub x: u32,
|
|
||||||
pub y: u32,
|
|
||||||
pub width: u32,
|
|
||||||
pub height: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CropRegion {
|
|
||||||
pub fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
|
|
||||||
Self { x, y, width, height }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn as_tuple(&self) -> (u32, u32, u32, u32) {
|
|
||||||
(self.x, self.y, self.width, self.height)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if region has valid dimensions.
|
|
||||||
pub fn is_valid(&self) -> bool {
|
|
||||||
self.width > 0 && self.height > 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/domain/document/operations/export.rs
|
|
||||||
//
|
|
||||||
// Document export operations to various formats.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use image::DynamicImage;
|
|
||||||
|
|
||||||
use crate::domain::document::core::document::DocResult;
|
|
||||||
|
|
||||||
/// Supported export formats.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum ExportFormat {
|
|
||||||
/// PNG format (lossless).
|
|
||||||
Png,
|
|
||||||
/// JPEG format (lossy).
|
|
||||||
Jpeg,
|
|
||||||
/// WebP format.
|
|
||||||
WebP,
|
|
||||||
/// PDF format.
|
|
||||||
Pdf,
|
|
||||||
/// SVG format (for vector documents).
|
|
||||||
Svg,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ExportFormat {
|
|
||||||
/// Get file extension for this format.
|
|
||||||
#[must_use]
|
|
||||||
pub fn extension(&self) -> &str {
|
|
||||||
match self {
|
|
||||||
Self::Png => "png",
|
|
||||||
Self::Jpeg => "jpg",
|
|
||||||
Self::WebP => "webp",
|
|
||||||
Self::Pdf => "pdf",
|
|
||||||
Self::Svg => "svg",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get MIME type for this format.
|
|
||||||
#[must_use]
|
|
||||||
pub fn mime_type(&self) -> &str {
|
|
||||||
match self {
|
|
||||||
Self::Png => "image/png",
|
|
||||||
Self::Jpeg => "image/jpeg",
|
|
||||||
Self::WebP => "image/webp",
|
|
||||||
Self::Pdf => "application/pdf",
|
|
||||||
Self::Svg => "image/svg+xml",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Detect format from file extension.
|
|
||||||
#[must_use]
|
|
||||||
pub fn from_path(path: &Path) -> Option<Self> {
|
|
||||||
let ext = path.extension()?.to_str()?.to_lowercase();
|
|
||||||
match ext.as_str() {
|
|
||||||
"png" => Some(Self::Png),
|
|
||||||
"jpg" | "jpeg" => Some(Self::Jpeg),
|
|
||||||
"webp" => Some(Self::WebP),
|
|
||||||
"pdf" => Some(Self::Pdf),
|
|
||||||
"svg" => Some(Self::Svg),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Export options for image formats.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct ImageExportOptions {
|
|
||||||
/// Quality setting (0-100) for lossy formats.
|
|
||||||
pub quality: u8,
|
|
||||||
/// Whether to preserve metadata (EXIF, etc.).
|
|
||||||
pub preserve_metadata: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ImageExportOptions {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
quality: 90,
|
|
||||||
preserve_metadata: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Export a raster image to a file.
|
|
||||||
///
|
|
||||||
/// This function handles format-specific encoding and options.
|
|
||||||
pub fn export_image(
|
|
||||||
img: &DynamicImage,
|
|
||||||
path: &Path,
|
|
||||||
format: ExportFormat,
|
|
||||||
_options: &ImageExportOptions,
|
|
||||||
) -> DocResult<()> {
|
|
||||||
match format {
|
|
||||||
ExportFormat::Png => {
|
|
||||||
img.save_with_format(path, image::ImageFormat::Png)?;
|
|
||||||
}
|
|
||||||
ExportFormat::Jpeg => {
|
|
||||||
// TODO: Apply quality settings
|
|
||||||
img.save_with_format(path, image::ImageFormat::Jpeg)?;
|
|
||||||
}
|
|
||||||
ExportFormat::WebP => {
|
|
||||||
img.save_with_format(path, image::ImageFormat::WebP)?;
|
|
||||||
}
|
|
||||||
ExportFormat::Pdf | ExportFormat::Svg => {
|
|
||||||
return Err(anyhow::anyhow!(
|
|
||||||
"Export to {} not yet implemented",
|
|
||||||
format.extension()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Export a document to a standard paper format (A4, Letter, etc.).
|
|
||||||
///
|
|
||||||
/// This function resizes the document to fit the target format while maintaining
|
|
||||||
/// aspect ratio, then exports it.
|
|
||||||
pub fn export_to_paper_format(
|
|
||||||
img: &DynamicImage,
|
|
||||||
path: &Path,
|
|
||||||
target_width: u32,
|
|
||||||
target_height: u32,
|
|
||||||
format: ExportFormat,
|
|
||||||
) -> DocResult<()> {
|
|
||||||
use image::imageops::FilterType;
|
|
||||||
|
|
||||||
// Resize to fit target dimensions
|
|
||||||
let resized = img.resize(target_width, target_height, FilterType::Lanczos3);
|
|
||||||
|
|
||||||
// Export with default options
|
|
||||||
let options = ImageExportOptions::default();
|
|
||||||
export_image(&resized, path, format, &options)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_format_extension() {
|
|
||||||
assert_eq!(ExportFormat::Png.extension(), "png");
|
|
||||||
assert_eq!(ExportFormat::Jpeg.extension(), "jpg");
|
|
||||||
assert_eq!(ExportFormat::Pdf.extension(), "pdf");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_format_from_path() {
|
|
||||||
assert_eq!(
|
|
||||||
ExportFormat::from_path(Path::new("test.png")),
|
|
||||||
Some(ExportFormat::Png)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
ExportFormat::from_path(Path::new("test.JPG")),
|
|
||||||
Some(ExportFormat::Jpeg)
|
|
||||||
);
|
|
||||||
assert_eq!(ExportFormat::from_path(Path::new("test.txt")), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/domain/document/operations/mod.rs
|
|
||||||
//
|
|
||||||
// Document operations: transformations, rendering, and export.
|
|
||||||
|
|
||||||
pub mod crop;
|
|
||||||
pub mod export;
|
|
||||||
pub mod render;
|
|
||||||
pub mod transform;
|
|
||||||
|
|
||||||
// Re-export CropRegion for convenience
|
|
||||||
pub use crop::CropRegion;
|
|
||||||
|
|
||||||
// Note: Low-level pixel operations (apply_rotation, apply_flip, crop_image)
|
|
||||||
// are internal helpers (pub(crate)) used only by document type implementations.
|
|
||||||
// Use high-level operations above for application and UI code.
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/domain/document/operations/render.rs
|
|
||||||
//
|
|
||||||
// Rendering operations for documents.
|
|
||||||
|
|
||||||
use cosmic::widget::image::Handle as ImageHandle;
|
|
||||||
use image::{DynamicImage, GenericImageView};
|
|
||||||
|
|
||||||
/// Create an image handle from RGBA pixel data.
|
|
||||||
///
|
|
||||||
/// This is the primary way to create image handles for display in the UI.
|
|
||||||
#[must_use]
|
|
||||||
pub fn create_image_handle(pixels: Vec<u8>, width: u32, height: u32) -> ImageHandle {
|
|
||||||
ImageHandle::from_rgba(width, height, pixels)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create an image handle from a `DynamicImage`.
|
|
||||||
///
|
|
||||||
/// Converts the image to RGBA8 format and creates a handle.
|
|
||||||
#[must_use]
|
|
||||||
pub fn create_image_handle_from_image(img: &DynamicImage) -> ImageHandle {
|
|
||||||
let (width, height) = img.dimensions();
|
|
||||||
let pixels = img.to_rgba8().into_raw();
|
|
||||||
create_image_handle(pixels, width, height)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Refresh image handle from a `DynamicImage`.
|
|
||||||
///
|
|
||||||
/// Alias for `create_image_handle_from_image` for compatibility.
|
|
||||||
#[must_use]
|
|
||||||
pub fn refresh_handle_from_image(img: &DynamicImage) -> ImageHandle {
|
|
||||||
create_image_handle_from_image(img)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculate scaled dimensions maintaining aspect ratio.
|
|
||||||
///
|
|
||||||
/// Returns (width, height) scaled by the given factor.
|
|
||||||
#[must_use]
|
|
||||||
pub fn scale_dimensions(width: u32, height: u32, scale: f64) -> (u32, u32) {
|
|
||||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
|
||||||
let scaled_width = (f64::from(width) * scale).round() as u32;
|
|
||||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
|
||||||
let scaled_height = (f64::from(height) * scale).round() as u32;
|
|
||||||
|
|
||||||
(scaled_width.max(1), scaled_height.max(1))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculate scale factor to fit dimensions into a target size.
|
|
||||||
///
|
|
||||||
/// Returns a scale factor that will make the image fit within the target
|
|
||||||
/// dimensions while maintaining aspect ratio.
|
|
||||||
#[must_use]
|
|
||||||
pub fn calculate_fit_scale(width: u32, height: u32, target_width: u32, target_height: u32) -> f64 {
|
|
||||||
if width == 0 || height == 0 {
|
|
||||||
return 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let width_scale = f64::from(target_width) / f64::from(width);
|
|
||||||
let height_scale = f64::from(target_height) / f64::from(height);
|
|
||||||
|
|
||||||
width_scale.min(height_scale)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculate scale factor to fill dimensions.
|
|
||||||
///
|
|
||||||
/// Returns a scale factor that will make the image fill the target dimensions
|
|
||||||
/// while maintaining aspect ratio (may crop).
|
|
||||||
#[must_use]
|
|
||||||
pub fn calculate_fill_scale(width: u32, height: u32, target_width: u32, target_height: u32) -> f64 {
|
|
||||||
if width == 0 || height == 0 {
|
|
||||||
return 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let width_scale = f64::from(target_width) / f64::from(width);
|
|
||||||
let height_scale = f64::from(target_height) / f64::from(height);
|
|
||||||
|
|
||||||
width_scale.max(height_scale)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_scale_dimensions() {
|
|
||||||
assert_eq!(scale_dimensions(100, 200, 2.0), (200, 400));
|
|
||||||
assert_eq!(scale_dimensions(100, 200, 0.5), (50, 100));
|
|
||||||
assert_eq!(scale_dimensions(100, 200, 0.0), (1, 1)); // Minimum 1x1
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_calculate_fit_scale() {
|
|
||||||
// Landscape image fitting into square
|
|
||||||
assert_eq!(calculate_fit_scale(200, 100, 100, 100), 0.5);
|
|
||||||
// Portrait image fitting into square
|
|
||||||
assert_eq!(calculate_fit_scale(100, 200, 100, 100), 0.5);
|
|
||||||
// Square into square
|
|
||||||
assert_eq!(calculate_fit_scale(100, 100, 100, 100), 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_calculate_fill_scale() {
|
|
||||||
// Landscape image filling square
|
|
||||||
assert_eq!(calculate_fill_scale(200, 100, 100, 100), 1.0);
|
|
||||||
// Portrait image filling square
|
|
||||||
assert_eq!(calculate_fill_scale(100, 200, 100, 100), 1.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,323 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/domain/document/operations/transform.rs
|
|
||||||
//
|
|
||||||
// Document transformation operations.
|
|
||||||
//
|
|
||||||
// This module provides two levels of transformation operations:
|
|
||||||
//
|
|
||||||
// 1. **Low-level operations** (internal) for direct pixel manipulation on raster images:
|
|
||||||
// - `apply_rotation()` - Rotate pixels by 90°, 180°, or 270° [pub(crate)]
|
|
||||||
// - `apply_flip()` - Flip pixels horizontally or vertically [pub(crate)]
|
|
||||||
// - `crop_image()` - Crop to a specific region [pub(crate)]
|
|
||||||
// These are used internally by document type implementations only.
|
|
||||||
//
|
|
||||||
// 2. **High-level operations** that work on any document type (raster, vector, PDF):
|
|
||||||
// - `rotate_document_cw()` - Rotate any document 90° clockwise
|
|
||||||
// - `rotate_document_ccw()` - Rotate any document 90° counter-clockwise
|
|
||||||
// - `flip_document_horizontal()` - Flip any document horizontally
|
|
||||||
// - `flip_document_vertical()` - Flip any document vertically
|
|
||||||
// - `rotate_document_to()` - Rotate to a specific angle
|
|
||||||
// - `reset_document_transforms()` - Reset all transformations
|
|
||||||
//
|
|
||||||
// ## Usage Example
|
|
||||||
//
|
|
||||||
// ```rust
|
|
||||||
// use crate::domain::document::operations::transform;
|
|
||||||
//
|
|
||||||
// // High-level: Works with any DocumentContent (RECOMMENDED)
|
|
||||||
// let mut document = DocumentContent::Raster(raster_doc);
|
|
||||||
// transform::rotate_document_cw(&mut document)?;
|
|
||||||
// transform::flip_document_horizontal(&mut document)?;
|
|
||||||
// ```
|
|
||||||
//
|
|
||||||
// Note: Low-level operations (apply_rotation, apply_flip, crop_image) are
|
|
||||||
// internal helpers used by document type implementations and are not part
|
|
||||||
// of the public API.
|
|
||||||
//
|
|
||||||
// The high-level operations use the `Transformable` trait and work across all
|
|
||||||
// document types (Raster, Vector, Portable), while low-level operations work
|
|
||||||
// directly on pixel data.
|
|
||||||
|
|
||||||
use image::{DynamicImage, GenericImageView};
|
|
||||||
|
|
||||||
use crate::domain::document::core::content::DocumentContent;
|
|
||||||
use crate::domain::document::core::document::{
|
|
||||||
DocResult, FlipDirection, Rotation, RotationMode, Transformable,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Apply a 90-degree rotation to a raster image.
|
|
||||||
///
|
|
||||||
/// This function performs the actual pixel manipulation for standard rotations.
|
|
||||||
/// Used internally by `RasterDocument` implementation.
|
|
||||||
#[must_use]
|
|
||||||
pub(crate) fn apply_rotation(img: DynamicImage, rotation: Rotation) -> DynamicImage {
|
|
||||||
use image::imageops::{rotate180, rotate270, rotate90};
|
|
||||||
|
|
||||||
match rotation {
|
|
||||||
Rotation::None => img,
|
|
||||||
Rotation::Cw90 => DynamicImage::ImageRgba8(rotate90(&img.to_rgba8())),
|
|
||||||
Rotation::Cw180 => DynamicImage::ImageRgba8(rotate180(&img.to_rgba8())),
|
|
||||||
Rotation::Cw270 => DynamicImage::ImageRgba8(rotate270(&img.to_rgba8())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply a flip transformation to a raster image.
|
|
||||||
///
|
|
||||||
/// This function performs the actual pixel manipulation for flip operations.
|
|
||||||
/// Used internally by `RasterDocument` and `PortableDocument` implementations.
|
|
||||||
#[must_use]
|
|
||||||
pub(crate) fn apply_flip(img: DynamicImage, direction: FlipDirection) -> DynamicImage {
|
|
||||||
use image::imageops::{flip_horizontal, flip_vertical};
|
|
||||||
|
|
||||||
match direction {
|
|
||||||
FlipDirection::Horizontal => DynamicImage::ImageRgba8(flip_horizontal(&img.to_rgba8())),
|
|
||||||
FlipDirection::Vertical => DynamicImage::ImageRgba8(flip_vertical(&img.to_rgba8())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Crop a raster image to the specified region.
|
|
||||||
///
|
|
||||||
/// Coordinates are in pixels relative to the top-left corner.
|
|
||||||
/// Returns None if the crop region is invalid.
|
|
||||||
/// Used internally for crop operations.
|
|
||||||
#[must_use]
|
|
||||||
pub(crate) fn crop_image(
|
|
||||||
img: &DynamicImage,
|
|
||||||
x: u32,
|
|
||||||
y: u32,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
) -> Option<DynamicImage> {
|
|
||||||
let (img_width, img_height) = img.dimensions();
|
|
||||||
|
|
||||||
// Validate crop region
|
|
||||||
if x >= img_width || y >= img_height {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clamp dimensions to image bounds
|
|
||||||
let crop_width = width.min(img_width - x);
|
|
||||||
let crop_height = height.min(img_height - y);
|
|
||||||
|
|
||||||
if crop_width == 0 || crop_height == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(img.crop_imm(x, y, crop_width, crop_height))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculate dimensions after rotation.
|
|
||||||
///
|
|
||||||
/// For 90° and 270° rotations, width and height are swapped.
|
|
||||||
#[must_use]
|
|
||||||
pub fn dimensions_after_rotation(width: u32, height: u32, rotation: Rotation) -> (u32, u32) {
|
|
||||||
match rotation {
|
|
||||||
Rotation::None | Rotation::Cw180 => (width, height),
|
|
||||||
Rotation::Cw90 | Rotation::Cw270 => (height, width),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// High-Level Document Operations (Type-agnostic)
|
|
||||||
// ============================================================================
|
|
||||||
//
|
|
||||||
// These operations work on ANY document type (Raster, Vector, Portable) through
|
|
||||||
// the DocumentContent abstraction. They should be preferred over direct trait
|
|
||||||
// calls when implementing UI commands or application logic.
|
|
||||||
//
|
|
||||||
// Benefits:
|
|
||||||
// - Single API for all document types
|
|
||||||
// - Handles rotation mode conversions (Standard ↔ Fine)
|
|
||||||
// - Returns Result for error handling
|
|
||||||
// - Future-proof for new document types
|
|
||||||
|
|
||||||
/// Rotate a document 90 degrees clockwise.
|
|
||||||
///
|
|
||||||
/// This operation works on any document type (Raster, Vector, Portable) by
|
|
||||||
/// delegating to the underlying document's `Transformable` implementation.
|
|
||||||
///
|
|
||||||
/// # Examples
|
|
||||||
///
|
|
||||||
/// ```no_run
|
|
||||||
/// use crate::domain::document::operations::transform::rotate_document_cw;
|
|
||||||
///
|
|
||||||
/// // Works with any document type
|
|
||||||
/// rotate_document_cw(&mut document)?;
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// # Implementation Details
|
|
||||||
///
|
|
||||||
/// - Raster: Actual pixel rotation via image operations
|
|
||||||
/// - Vector: Viewport matrix transformation (lossless)
|
|
||||||
/// - Portable: View rotation, rendered by backend
|
|
||||||
pub fn rotate_document_cw(document: &mut DocumentContent) -> DocResult<()> {
|
|
||||||
let new_rotation_mode = document.transform_state().rotation.rotate_cw();
|
|
||||||
|
|
||||||
match new_rotation_mode {
|
|
||||||
RotationMode::Standard(rot) => {
|
|
||||||
document.rotate(rot);
|
|
||||||
}
|
|
||||||
RotationMode::Fine(deg) => {
|
|
||||||
// Convert to nearest 90° rotation
|
|
||||||
let normalized = ((deg / 90.0).round() as i16 * 90) % 360;
|
|
||||||
let rot = match normalized {
|
|
||||||
0 => Rotation::None,
|
|
||||||
90 => Rotation::Cw90,
|
|
||||||
180 => Rotation::Cw180,
|
|
||||||
270 => Rotation::Cw270,
|
|
||||||
_ => Rotation::None,
|
|
||||||
};
|
|
||||||
document.rotate(rot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rotate a document 90 degrees counter-clockwise.
|
|
||||||
///
|
|
||||||
/// This operation works on any document type (Raster, Vector, Portable) by
|
|
||||||
/// delegating to the underlying document's `Transformable` implementation.
|
|
||||||
///
|
|
||||||
/// # Examples
|
|
||||||
///
|
|
||||||
/// ```no_run
|
|
||||||
/// use crate::domain::document::operations::transform::rotate_document_ccw;
|
|
||||||
///
|
|
||||||
/// rotate_document_ccw(&mut document)?;
|
|
||||||
/// ```
|
|
||||||
pub fn rotate_document_ccw(document: &mut DocumentContent) -> DocResult<()> {
|
|
||||||
let new_rotation_mode = document.transform_state().rotation.rotate_ccw();
|
|
||||||
|
|
||||||
match new_rotation_mode {
|
|
||||||
RotationMode::Standard(rot) => {
|
|
||||||
document.rotate(rot);
|
|
||||||
}
|
|
||||||
RotationMode::Fine(deg) => {
|
|
||||||
// Convert to nearest 90° rotation
|
|
||||||
let normalized = ((deg / 90.0).round() as i16 * 90 + 360) % 360;
|
|
||||||
let rot = match normalized {
|
|
||||||
0 => Rotation::None,
|
|
||||||
90 => Rotation::Cw90,
|
|
||||||
180 => Rotation::Cw180,
|
|
||||||
270 => Rotation::Cw270,
|
|
||||||
_ => Rotation::None,
|
|
||||||
};
|
|
||||||
document.rotate(rot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Flip a document horizontally (mirror left-right).
|
|
||||||
///
|
|
||||||
/// This operation works on any document type by delegating to the underlying
|
|
||||||
/// document's `Transformable` implementation.
|
|
||||||
///
|
|
||||||
/// # Examples
|
|
||||||
///
|
|
||||||
/// ```no_run
|
|
||||||
/// use crate::domain::document::operations::transform::flip_document_horizontal;
|
|
||||||
///
|
|
||||||
/// flip_document_horizontal(&mut document)?;
|
|
||||||
/// ```
|
|
||||||
pub fn flip_document_horizontal(document: &mut DocumentContent) -> DocResult<()> {
|
|
||||||
document.flip(FlipDirection::Horizontal);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Flip a document vertically (mirror top-bottom).
|
|
||||||
///
|
|
||||||
/// This operation works on any document type by delegating to the underlying
|
|
||||||
/// document's `Transformable` implementation.
|
|
||||||
///
|
|
||||||
/// # Examples
|
|
||||||
///
|
|
||||||
/// ```no_run
|
|
||||||
/// use crate::domain::document::operations::transform::flip_document_vertical;
|
|
||||||
///
|
|
||||||
/// flip_document_vertical(&mut document)?;
|
|
||||||
/// ```
|
|
||||||
pub fn flip_document_vertical(document: &mut DocumentContent) -> DocResult<()> {
|
|
||||||
document.flip(FlipDirection::Vertical);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rotate a document to a specific angle (0°, 90°, 180°, or 270°).
|
|
||||||
///
|
|
||||||
/// This operation works on any document type by delegating to the underlying
|
|
||||||
/// document's `Transformable` implementation.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `document` - The document to rotate
|
|
||||||
/// * `rotation` - Target rotation angle
|
|
||||||
///
|
|
||||||
/// # Examples
|
|
||||||
///
|
|
||||||
/// ```no_run
|
|
||||||
/// use crate::domain::document::core::document::Rotation;
|
|
||||||
/// use crate::domain::document::operations::transform::rotate_document_to;
|
|
||||||
///
|
|
||||||
/// // Rotate to 180 degrees
|
|
||||||
/// rotate_document_to(&mut document, Rotation::Cw180)?;
|
|
||||||
/// ```
|
|
||||||
pub fn rotate_document_to(document: &mut DocumentContent, rotation: Rotation) -> DocResult<()> {
|
|
||||||
document.rotate(rotation);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reset all transformations on a document.
|
|
||||||
///
|
|
||||||
/// This resets the document to its original state (no rotation, no flips).
|
|
||||||
/// Useful for implementing "Reset View" functionality.
|
|
||||||
///
|
|
||||||
/// # Examples
|
|
||||||
///
|
|
||||||
/// ```no_run
|
|
||||||
/// use crate::domain::document::operations::transform::reset_document_transforms;
|
|
||||||
///
|
|
||||||
/// // Undo all rotations and flips
|
|
||||||
/// reset_document_transforms(&mut document)?;
|
|
||||||
/// ```
|
|
||||||
pub fn reset_document_transforms(document: &mut DocumentContent) -> DocResult<()> {
|
|
||||||
// Reset to no rotation
|
|
||||||
document.rotate(Rotation::None);
|
|
||||||
|
|
||||||
// Reset flips by checking current state and flipping back if needed
|
|
||||||
let state = document.transform_state();
|
|
||||||
if state.flip_h {
|
|
||||||
document.flip(FlipDirection::Horizontal);
|
|
||||||
}
|
|
||||||
if state.flip_v {
|
|
||||||
document.flip(FlipDirection::Vertical);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_dimensions_after_rotation() {
|
|
||||||
assert_eq!(
|
|
||||||
dimensions_after_rotation(100, 200, Rotation::None),
|
|
||||||
(100, 200)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
dimensions_after_rotation(100, 200, Rotation::Cw90),
|
|
||||||
(200, 100)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
dimensions_after_rotation(100, 200, Rotation::Cw180),
|
|
||||||
(100, 200)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
dimensions_after_rotation(100, 200, Rotation::Cw270),
|
|
||||||
(200, 100)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/domain/document/types/mod.rs
|
|
||||||
//
|
|
||||||
// Concrete document type implementations.
|
|
||||||
|
|
||||||
pub mod raster;
|
|
||||||
#[cfg(feature = "vector")]
|
|
||||||
pub mod vector;
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
pub mod portable;
|
|
||||||
|
|
@ -1,473 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/domain/document/types/portable.rs
|
|
||||||
//
|
|
||||||
// Portable documents (PDF) with poppler backend.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
/// PDF page render quality multiplier (2.0 = double resolution for sharp display).
|
|
||||||
const PDF_RENDER_QUALITY: f64 = 3.0;
|
|
||||||
|
|
||||||
/// PDF thumbnail size multiplier (0.25 = 25% for fast preview generation).
|
|
||||||
const PDF_THUMBNAIL_SIZE: f64 = 0.25;
|
|
||||||
|
|
||||||
use cairo::{Context, Format, ImageSurface};
|
|
||||||
use image::{DynamicImage, GenericImageView};
|
|
||||||
use poppler::PopplerDocument;
|
|
||||||
|
|
||||||
use cosmic::widget::image::Handle as ImageHandle;
|
|
||||||
|
|
||||||
use crate::domain::document::core::document::{
|
|
||||||
DocResult, DocumentInfo, FlipDirection, MultiPage, MultiPageThumbnails, Renderable,
|
|
||||||
RenderOutput, Rotation, RotationMode, TransformState, Transformable,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Represents a portable document (PDF).
|
|
||||||
pub struct PortableDocument {
|
|
||||||
/// The parsed PDF document.
|
|
||||||
document: PopplerDocument,
|
|
||||||
/// Path to the source file (for caching).
|
|
||||||
source_path: PathBuf,
|
|
||||||
/// Total number of pages.
|
|
||||||
num_pages: usize,
|
|
||||||
/// Current page index (0-based).
|
|
||||||
page_index: usize,
|
|
||||||
/// Current transformation state.
|
|
||||||
transform: TransformState,
|
|
||||||
/// Current rendered page as image.
|
|
||||||
pub rendered: DynamicImage,
|
|
||||||
/// Image handle for display.
|
|
||||||
pub handle: ImageHandle,
|
|
||||||
/// Cached thumbnail handles for each page (None = not yet generated).
|
|
||||||
thumbnail_cache: Option<Vec<ImageHandle>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PortableDocument {
|
|
||||||
/// Open a PDF document and render the first page.
|
|
||||||
pub fn open(path: &Path) -> anyhow::Result<Self> {
|
|
||||||
let document = PopplerDocument::new_from_file(path, None)
|
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to parse PDF: {e}"))?;
|
|
||||||
|
|
||||||
let num_pages = document.get_n_pages();
|
|
||||||
if num_pages == 0 {
|
|
||||||
return Err(anyhow::anyhow!("PDF has no pages"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let rendered = Self::render_page(&document, 0, RotationMode::Standard(Rotation::None))?;
|
|
||||||
let handle = Self::create_image_handle_from_image(&rendered);
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
document,
|
|
||||||
source_path: path.to_path_buf(),
|
|
||||||
num_pages,
|
|
||||||
page_index: 0,
|
|
||||||
transform: TransformState::default(),
|
|
||||||
rendered,
|
|
||||||
handle,
|
|
||||||
thumbnail_cache: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the current pixel dimensions (width, height).
|
|
||||||
#[must_use]
|
|
||||||
pub fn dimensions(&self) -> (u32, u32) {
|
|
||||||
self.rendered.dimensions()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current image handle.
|
|
||||||
#[must_use]
|
|
||||||
pub fn handle(&self) -> ImageHandle {
|
|
||||||
self.handle.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get native dimensions of current page.
|
|
||||||
#[must_use]
|
|
||||||
pub fn native_dimensions(&self) -> (u32, u32) {
|
|
||||||
self.rendered.dimensions()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the number of thumbnails currently loaded.
|
|
||||||
pub fn thumbnails_loaded(&self) -> usize {
|
|
||||||
self.thumbnail_cache.as_ref().map_or(0, Vec::len)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<ImageHandle> {
|
|
||||||
self.thumbnail_cache
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|cache| cache.get(page).cloned())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
|
|
||||||
/// Extract metadata for this portable document.
|
|
||||||
pub fn extract_meta(&self, path: &Path) -> crate::domain::document::core::metadata::DocumentMeta {
|
|
||||||
use crate::domain::document::core::metadata::{BasicMeta, DocumentMeta};
|
|
||||||
|
|
||||||
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 (width, height) = self.dimensions();
|
|
||||||
let format = format!("PDF ({} pages)", self.num_pages);
|
|
||||||
|
|
||||||
let basic = BasicMeta {
|
|
||||||
file_name,
|
|
||||||
file_path,
|
|
||||||
format,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
file_size,
|
|
||||||
color_type: "Rendered".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
DocumentMeta { basic, exif: None }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Crop the current page to the specified rectangle.
|
|
||||||
/// Works on rendered output (raster).
|
|
||||||
pub fn crop(&mut self, x: u32, y: u32, width: u32, height: u32) -> Result<(), String> {
|
|
||||||
let (img_width, img_height) = self.rendered.dimensions();
|
|
||||||
|
|
||||||
// Validate crop region
|
|
||||||
if x >= img_width || y >= img_height {
|
|
||||||
return Err(format!(
|
|
||||||
"Crop region ({}, {}) is outside rendered bounds ({}, {})",
|
|
||||||
x, y, img_width, img_height
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clamp dimensions
|
|
||||||
let crop_width = width.min(img_width - x);
|
|
||||||
let crop_height = height.min(img_height - y);
|
|
||||||
|
|
||||||
if crop_width == 0 || crop_height == 0 {
|
|
||||||
return Err("Crop region has zero width or height".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Crop rendered image
|
|
||||||
self.rendered = self.rendered.crop_imm(x, y, crop_width, crop_height);
|
|
||||||
|
|
||||||
// Update handle
|
|
||||||
self.handle = Self::create_image_handle_from_image(&self.rendered);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
fn create_image_handle_from_image(img: &DynamicImage) -> ImageHandle {
|
|
||||||
let (width, height) = img.dimensions();
|
|
||||||
let pixels = img.to_rgba8().into_raw();
|
|
||||||
ImageHandle::from_rgba(width, height, pixels)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initialize thumbnail cache (empty, ready for incremental loading).
|
|
||||||
fn init_thumbnail_cache(&mut self) {
|
|
||||||
if self.thumbnail_cache.is_none() {
|
|
||||||
self.thumbnail_cache = Some(Vec::with_capacity(self.num_pages));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate a single thumbnail page. Returns the next page to generate, or None if done.
|
|
||||||
pub fn generate_thumbnail_page(&mut self, page: usize) -> Option<usize> {
|
|
||||||
// Initialize cache if needed.
|
|
||||||
self.init_thumbnail_cache();
|
|
||||||
|
|
||||||
// Check if we should generate this page.
|
|
||||||
let should_generate = {
|
|
||||||
let cache = self.thumbnail_cache.as_ref()?;
|
|
||||||
page >= cache.len() && page < self.num_pages
|
|
||||||
};
|
|
||||||
|
|
||||||
if should_generate {
|
|
||||||
let handle = self.load_or_generate_thumbnail(page);
|
|
||||||
if let Some(cache) = self.thumbnail_cache.as_mut() {
|
|
||||||
cache.push(handle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return next page if not done.
|
|
||||||
let next = page + 1;
|
|
||||||
if next < self.num_pages {
|
|
||||||
Some(next)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load thumbnail from cache or generate and cache it.
|
|
||||||
fn load_or_generate_thumbnail(&self, page: usize) -> ImageHandle {
|
|
||||||
// TODO: Re-enable cache once infrastructure layer is set up
|
|
||||||
// if let Some(handle) = cache::load_thumbnail(&self.source_path, page) {
|
|
||||||
// return handle;
|
|
||||||
// }
|
|
||||||
|
|
||||||
match Self::render_page_at_scale(
|
|
||||||
&self.document,
|
|
||||||
page,
|
|
||||||
RotationMode::Standard(Rotation::None),
|
|
||||||
PDF_THUMBNAIL_SIZE,
|
|
||||||
) {
|
|
||||||
Ok(img) => {
|
|
||||||
// TODO: Re-enable cache once infrastructure layer is set up
|
|
||||||
// let _ = cache::save_thumbnail(&self.source_path, page, &img);
|
|
||||||
Self::create_image_handle_from_image(&img)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("Failed to generate thumbnail for page {page}: {e}");
|
|
||||||
ImageHandle::from_rgba(1, 1, vec![0, 0, 0, 0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render a specific page from the document to an image.
|
|
||||||
fn render_page(
|
|
||||||
document: &PopplerDocument,
|
|
||||||
page_index: usize,
|
|
||||||
rotation: RotationMode,
|
|
||||||
) -> anyhow::Result<DynamicImage> {
|
|
||||||
Self::render_page_at_scale(document, page_index, rotation, PDF_RENDER_QUALITY)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render a specific page at a given scale.
|
|
||||||
fn render_page_at_scale(
|
|
||||||
document: &PopplerDocument,
|
|
||||||
page_index: usize,
|
|
||||||
rotation: RotationMode,
|
|
||||||
scale: f64,
|
|
||||||
) -> anyhow::Result<DynamicImage> {
|
|
||||||
let page = document
|
|
||||||
.get_page(page_index)
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Failed to get page {page_index}"))?;
|
|
||||||
|
|
||||||
let (page_width, page_height) = page.get_size();
|
|
||||||
let rotation_degrees = rotation.to_degrees() as i16;
|
|
||||||
|
|
||||||
let (width, height) = if rotation_degrees == 90 || rotation_degrees == 270 {
|
|
||||||
(page_height, page_width)
|
|
||||||
} else {
|
|
||||||
(page_width, page_height)
|
|
||||||
};
|
|
||||||
|
|
||||||
#[allow(clippy::cast_possible_truncation)]
|
|
||||||
let scaled_width = (width * scale) as i32;
|
|
||||||
#[allow(clippy::cast_possible_truncation)]
|
|
||||||
let scaled_height = (height * scale) as i32;
|
|
||||||
|
|
||||||
let surface = ImageSurface::create(Format::ARgb32, scaled_width, scaled_height)
|
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to create Cairo surface: {e}"))?;
|
|
||||||
|
|
||||||
let context = Context::new(&surface)
|
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to create Cairo context: {e}"))?;
|
|
||||||
|
|
||||||
// Fill with white background.
|
|
||||||
context.set_source_rgb(1.0, 1.0, 1.0);
|
|
||||||
let _ = context.paint();
|
|
||||||
|
|
||||||
context.scale(scale, scale);
|
|
||||||
|
|
||||||
if rotation != RotationMode::Standard(Rotation::None) {
|
|
||||||
let center_x = width / 2.0;
|
|
||||||
let center_y = height / 2.0;
|
|
||||||
context.translate(center_x, center_y);
|
|
||||||
context.rotate(f64::from(rotation_degrees) * std::f64::consts::PI / 180.0);
|
|
||||||
context.translate(-page_width / 2.0, -page_height / 2.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
page.render(&context);
|
|
||||||
|
|
||||||
drop(context);
|
|
||||||
surface.flush();
|
|
||||||
|
|
||||||
// Direct conversion from Cairo surface to DynamicImage without PNG round-trip.
|
|
||||||
// This preserves exact pixel data and avoids ARgb32 → PNG → RGBA conversion artifacts.
|
|
||||||
let width = surface.width() as u32;
|
|
||||||
let height = surface.height() as u32;
|
|
||||||
let stride = surface.stride();
|
|
||||||
|
|
||||||
let data = surface
|
|
||||||
.take_data()
|
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to take Cairo surface data: {e}"))?;
|
|
||||||
|
|
||||||
// ARgb32 in Cairo is 4 bytes per pixel (A, R, G, B) with stride padding.
|
|
||||||
// Convert to standard RGBA by copying pixel-by-pixel, skipping stride padding.
|
|
||||||
let mut rgba_bytes = Vec::with_capacity((width * 4) as usize);
|
|
||||||
for y in 0..height {
|
|
||||||
let row_offset = (y as i32 * stride) as usize;
|
|
||||||
for x in 0..width {
|
|
||||||
let col_offset = (x as i32 * 4) as usize;
|
|
||||||
let idx = row_offset + col_offset;
|
|
||||||
// Cairo ARgb32: bytes are [A, R, G, B]
|
|
||||||
let a = data[idx];
|
|
||||||
let r = data[idx + 1];
|
|
||||||
let g = data[idx + 2];
|
|
||||||
let b = data[idx + 3];
|
|
||||||
// Output standard RGBA: [R, G, B, A]
|
|
||||||
rgba_bytes.extend_from_slice(&[r, g, b, a]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(DynamicImage::ImageRgba8(image::RgbaImage::from_raw(width, height, rgba_bytes)
|
|
||||||
.expect("Failed to create RgbaImage from Cairo surface data")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Re-render the current page with current transform.
|
|
||||||
fn rerender(&mut self) {
|
|
||||||
match Self::render_page(&self.document, self.page_index, self.transform.rotation) {
|
|
||||||
Ok(mut rendered) => {
|
|
||||||
// Apply flip transformations to the rendered result
|
|
||||||
if self.transform.flip_h {
|
|
||||||
rendered = Self::apply_flip(rendered, FlipDirection::Horizontal);
|
|
||||||
}
|
|
||||||
if self.transform.flip_v {
|
|
||||||
rendered = Self::apply_flip(rendered, FlipDirection::Vertical);
|
|
||||||
}
|
|
||||||
self.rendered = rendered;
|
|
||||||
self.handle = Self::create_image_handle_from_image(&self.rendered);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to render PDF page: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_flip(img: DynamicImage, direction: FlipDirection) -> DynamicImage {
|
|
||||||
use image::imageops::{flip_horizontal, flip_vertical};
|
|
||||||
match direction {
|
|
||||||
FlipDirection::Horizontal => DynamicImage::ImageRgba8(flip_horizontal(&img.to_rgba8())),
|
|
||||||
FlipDirection::Vertical => DynamicImage::ImageRgba8(flip_vertical(&img.to_rgba8())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Navigate to the next page.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn next_page(&mut self) -> bool {
|
|
||||||
if self.page_index + 1 < self.num_pages {
|
|
||||||
self.page_index += 1;
|
|
||||||
self.rerender();
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Navigate to the previous page.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn prev_page(&mut self) -> bool {
|
|
||||||
if self.page_index > 0 {
|
|
||||||
self.page_index -= 1;
|
|
||||||
self.rerender();
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Trait Implementations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
impl Renderable for PortableDocument {
|
|
||||||
fn render(&mut self, _scale: f64) -> DocResult<RenderOutput> {
|
|
||||||
// PDF rendering quality is fixed for now (PDF_RENDER_QUALITY)
|
|
||||||
let (width, height) = self.dimensions();
|
|
||||||
Ok(RenderOutput {
|
|
||||||
handle: self.handle.clone(),
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn info(&self) -> DocumentInfo {
|
|
||||||
let (width, height) = self.dimensions();
|
|
||||||
DocumentInfo {
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
format: "PDF".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Transformable for PortableDocument {
|
|
||||||
fn rotate(&mut self, rotation: Rotation) {
|
|
||||||
self.transform.rotation = RotationMode::Standard(rotation);
|
|
||||||
self.rerender();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flip(&mut self, direction: FlipDirection) {
|
|
||||||
match direction {
|
|
||||||
FlipDirection::Horizontal => self.transform.flip_h = !self.transform.flip_h,
|
|
||||||
FlipDirection::Vertical => self.transform.flip_v = !self.transform.flip_v,
|
|
||||||
}
|
|
||||||
self.rerender();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn transform_state(&self) -> TransformState {
|
|
||||||
self.transform
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MultiPage for PortableDocument {
|
|
||||||
fn page_count(&self) -> usize {
|
|
||||||
self.num_pages
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current_page(&self) -> usize {
|
|
||||||
self.page_index
|
|
||||||
}
|
|
||||||
|
|
||||||
fn go_to_page(&mut self, page: usize) -> DocResult<()> {
|
|
||||||
if page >= self.num_pages {
|
|
||||||
return Err(anyhow::anyhow!(
|
|
||||||
"Page {} out of range (0-{})",
|
|
||||||
page,
|
|
||||||
self.num_pages - 1
|
|
||||||
));
|
|
||||||
}
|
|
||||||
self.page_index = page;
|
|
||||||
self.rerender();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MultiPageThumbnails for PortableDocument {
|
|
||||||
fn thumbnails_ready(&self) -> bool {
|
|
||||||
self.thumbnail_cache
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|c| c.len() >= self.num_pages)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn thumbnails_loaded(&self) -> bool {
|
|
||||||
let loaded = PortableDocument::thumbnails_loaded(self);
|
|
||||||
loaded >= self.num_pages
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_thumbnail_page(&mut self, page: usize) -> DocResult<()> {
|
|
||||||
PortableDocument::generate_thumbnail_page(self, page);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_all_thumbnails(&mut self) -> DocResult<()> {
|
|
||||||
if self.thumbnails_ready() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
self.init_thumbnail_cache();
|
|
||||||
for page in 0..self.num_pages {
|
|
||||||
PortableDocument::generate_thumbnail_page(self, page);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_thumbnail(&mut self, page: usize) -> DocResult<Option<ImageHandle>> {
|
|
||||||
Ok(self
|
|
||||||
.thumbnail_cache
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|cache| cache.get(page).cloned()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,353 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/domain/document/types/raster.rs
|
|
||||||
//
|
|
||||||
// Raster image document support (PNG, JPEG, WebP, etc.).
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use image::{DynamicImage, GenericImageView, ImageReader};
|
|
||||||
|
|
||||||
use cosmic::widget::image::Handle as ImageHandle;
|
|
||||||
|
|
||||||
use crate::domain::document::core::document::{
|
|
||||||
DocResult, DocumentInfo, FlipDirection, InterpolationQuality, Renderable, RenderOutput,
|
|
||||||
Rotation, RotationMode, TransformState, Transformable,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Represents a raster image document (PNG, JPEG, WebP, ...).
|
|
||||||
pub struct RasterDocument {
|
|
||||||
/// The decoded image document.
|
|
||||||
document: DynamicImage,
|
|
||||||
/// Native width (original, before transforms).
|
|
||||||
native_width: u32,
|
|
||||||
/// Native height (original, before transforms).
|
|
||||||
native_height: u32,
|
|
||||||
/// Current transformation state.
|
|
||||||
transform: TransformState,
|
|
||||||
/// Cached handle for rendering.
|
|
||||||
handle: ImageHandle,
|
|
||||||
/// Accumulated fine rotation angle in degrees.
|
|
||||||
fine_rotation_angle: f32,
|
|
||||||
/// Interpolation quality for fine rotation and resize operations.
|
|
||||||
interpolation_quality: InterpolationQuality,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RasterDocument {
|
|
||||||
/// Load a raster document from disk.
|
|
||||||
pub fn open(path: &Path) -> image::ImageResult<Self> {
|
|
||||||
let document = ImageReader::open(path)?.decode()?;
|
|
||||||
let (native_width, native_height) = document.dimensions();
|
|
||||||
let handle = Self::create_image_handle_from_image(&document);
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
document,
|
|
||||||
native_width,
|
|
||||||
native_height,
|
|
||||||
transform: TransformState::default(),
|
|
||||||
handle,
|
|
||||||
fine_rotation_angle: 0.0,
|
|
||||||
interpolation_quality: InterpolationQuality::default(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the current pixel dimensions (width, height) after transforms.
|
|
||||||
#[must_use]
|
|
||||||
pub fn dimensions(&self) -> (u32, u32) {
|
|
||||||
self.document.dimensions()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current image handle.
|
|
||||||
#[must_use]
|
|
||||||
pub fn handle(&self) -> ImageHandle {
|
|
||||||
self.handle.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save the current document to disk.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn save(&self, path: &Path) -> image::ImageResult<()> {
|
|
||||||
self.document.save(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the underlying `DynamicImage`.
|
|
||||||
#[must_use]
|
|
||||||
pub fn image(&self) -> &DynamicImage {
|
|
||||||
&self.document
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get native dimensions (before transformations).
|
|
||||||
#[must_use]
|
|
||||||
pub fn native_dimensions(&self) -> (u32, u32) {
|
|
||||||
(self.native_width, self.native_height)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a reference to the rendered image (for cropping from screen coordinates).
|
|
||||||
pub fn get_rendered_image(&self) -> &DynamicImage {
|
|
||||||
&self.document
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Crop the document to a specified rectangular region (in-place).
|
|
||||||
///
|
|
||||||
/// Coordinates are in pixels relative to the current image dimensions.
|
|
||||||
/// The crop region is clamped to image bounds if it extends beyond.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns an error if the crop region is completely outside the image bounds.
|
|
||||||
pub fn crop(&mut self, x: u32, y: u32, width: u32, height: u32) -> Result<(), String> {
|
|
||||||
let (img_width, img_height) = self.document.dimensions();
|
|
||||||
|
|
||||||
// Validate crop region
|
|
||||||
if x >= img_width || y >= img_height {
|
|
||||||
return Err(format!(
|
|
||||||
"Crop region ({}, {}) is outside image bounds ({}, {})",
|
|
||||||
x, y, img_width, img_height
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clamp dimensions to image bounds
|
|
||||||
let crop_width = width.min(img_width - x);
|
|
||||||
let crop_height = height.min(img_height - y);
|
|
||||||
|
|
||||||
if crop_width == 0 || crop_height == 0 {
|
|
||||||
return Err("Crop region has zero width or height".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply crop
|
|
||||||
self.document = self.document.crop_imm(x, y, crop_width, crop_height);
|
|
||||||
|
|
||||||
// Update native dimensions to the cropped size
|
|
||||||
self.native_width = crop_width;
|
|
||||||
self.native_height = crop_height;
|
|
||||||
|
|
||||||
// Reset transformations since we have a new "native" image
|
|
||||||
self.transform = TransformState::default();
|
|
||||||
self.fine_rotation_angle = 0.0;
|
|
||||||
|
|
||||||
// Regenerate handle
|
|
||||||
self.handle = Self::create_image_handle_from_image(&self.document);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
/// Crop the image to the specified rectangle and return as DynamicImage.
|
|
||||||
///
|
|
||||||
/// This does NOT modify the document - it's used for exporting cropped images.
|
|
||||||
pub fn crop_to_image(
|
|
||||||
&self,
|
|
||||||
x: u32,
|
|
||||||
y: u32,
|
|
||||||
width: u32,
|
|
||||||
height: u32,
|
|
||||||
) -> Result<DynamicImage, String> {
|
|
||||||
let (img_width, img_height) = self.document.dimensions();
|
|
||||||
|
|
||||||
// Validate crop region
|
|
||||||
if x >= img_width || y >= img_height {
|
|
||||||
return Err(format!(
|
|
||||||
"Crop rectangle out of bounds: {}x{} at ({}, {}) exceeds image size {}x{}",
|
|
||||||
width, height, x, y, img_width, img_height
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clamp dimensions to image bounds
|
|
||||||
let crop_width = width.min(img_width - x);
|
|
||||||
let crop_height = height.min(img_height - y);
|
|
||||||
|
|
||||||
if crop_width == 0 || crop_height == 0 {
|
|
||||||
return Err("Crop region has zero width or height".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let cropped = self.document.crop_imm(x, y, crop_width, crop_height);
|
|
||||||
Ok(cropped)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract metadata for this raster document.
|
|
||||||
///
|
|
||||||
/// Returns basic metadata (dimensions, format, file size) and EXIF data if available.
|
|
||||||
pub fn extract_meta(
|
|
||||||
&self,
|
|
||||||
path: &Path,
|
|
||||||
) -> crate::domain::document::core::metadata::DocumentMeta {
|
|
||||||
use crate::domain::document::core::metadata::{BasicMeta, DocumentMeta, ExifMeta};
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Detect format from path
|
|
||||||
let format = path
|
|
||||||
.extension()
|
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.unwrap_or("unknown")
|
|
||||||
.to_uppercase();
|
|
||||||
|
|
||||||
let color_type = format!("{:?}", self.document.color());
|
|
||||||
|
|
||||||
let basic = BasicMeta {
|
|
||||||
file_name,
|
|
||||||
file_path,
|
|
||||||
format,
|
|
||||||
width: self.native_width,
|
|
||||||
height: self.native_height,
|
|
||||||
file_size,
|
|
||||||
color_type,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try to extract EXIF data
|
|
||||||
let exif = std::fs::read(path)
|
|
||||||
.ok()
|
|
||||||
.and_then(|bytes| ExifMeta::from_bytes(&bytes));
|
|
||||||
|
|
||||||
DocumentMeta { basic, exif }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resize the document to specific dimensions (for format conversion).
|
|
||||||
///
|
|
||||||
/// This is useful for converting images to standard paper formats (A4, US Letter, etc.).
|
|
||||||
pub fn resize_to_format(&mut self, target_width: u32, target_height: u32) {
|
|
||||||
use image::imageops::FilterType;
|
|
||||||
|
|
||||||
let filter = match self.interpolation_quality {
|
|
||||||
InterpolationQuality::Fast => FilterType::Nearest,
|
|
||||||
InterpolationQuality::Balanced => FilterType::Triangle,
|
|
||||||
InterpolationQuality::Best => FilterType::CatmullRom,
|
|
||||||
};
|
|
||||||
|
|
||||||
self.document = self
|
|
||||||
.document
|
|
||||||
.resize_exact(target_width, target_height, filter);
|
|
||||||
self.handle = Self::create_image_handle_from_image(&self.document);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
fn create_image_handle_from_image(img: &DynamicImage) -> ImageHandle {
|
|
||||||
let (width, height) = img.dimensions();
|
|
||||||
let pixels = img.to_rgba8().into_raw();
|
|
||||||
ImageHandle::from_rgba(width, height, pixels)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_rotation(img: DynamicImage, rotation: Rotation) -> DynamicImage {
|
|
||||||
use image::imageops::{rotate180, rotate270, rotate90};
|
|
||||||
match rotation {
|
|
||||||
Rotation::None => img,
|
|
||||||
Rotation::Cw90 => DynamicImage::ImageRgba8(rotate90(&img.to_rgba8())),
|
|
||||||
Rotation::Cw180 => DynamicImage::ImageRgba8(rotate180(&img.to_rgba8())),
|
|
||||||
Rotation::Cw270 => DynamicImage::ImageRgba8(rotate270(&img.to_rgba8())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_flip(img: DynamicImage, direction: FlipDirection) -> DynamicImage {
|
|
||||||
use image::imageops::{flip_horizontal, flip_vertical};
|
|
||||||
match direction {
|
|
||||||
FlipDirection::Horizontal => DynamicImage::ImageRgba8(flip_horizontal(&img.to_rgba8())),
|
|
||||||
FlipDirection::Vertical => DynamicImage::ImageRgba8(flip_vertical(&img.to_rgba8())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Trait Implementations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
impl Renderable for RasterDocument {
|
|
||||||
fn render(&mut self, _scale: f64) -> DocResult<RenderOutput> {
|
|
||||||
// Raster images don't re-render at different scales (lossy),
|
|
||||||
// we just return the current handle.
|
|
||||||
let (width, height) = self.dimensions();
|
|
||||||
Ok(RenderOutput {
|
|
||||||
handle: self.handle.clone(),
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn info(&self) -> DocumentInfo {
|
|
||||||
DocumentInfo {
|
|
||||||
width: self.native_width,
|
|
||||||
height: self.native_height,
|
|
||||||
format: "Raster".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Transformable for RasterDocument {
|
|
||||||
fn rotate(&mut self, rotation: Rotation) {
|
|
||||||
// Extract current rotation in degrees
|
|
||||||
let current_deg = match self.transform.rotation {
|
|
||||||
RotationMode::Standard(r) => r.to_degrees(),
|
|
||||||
RotationMode::Fine(_) => {
|
|
||||||
// If we have fine rotation, reset it and apply standard rotation
|
|
||||||
self.fine_rotation_angle = 0.0;
|
|
||||||
0
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let new_deg = rotation.to_degrees();
|
|
||||||
let diff_deg = (new_deg - current_deg + 360) % 360;
|
|
||||||
|
|
||||||
if diff_deg != 0 {
|
|
||||||
let rotation_to_apply = match diff_deg {
|
|
||||||
90 => Rotation::Cw90,
|
|
||||||
180 => Rotation::Cw180,
|
|
||||||
270 => Rotation::Cw270,
|
|
||||||
_ => unreachable!("Invalid rotation diff: {}", diff_deg),
|
|
||||||
};
|
|
||||||
self.document = Self::apply_rotation(
|
|
||||||
std::mem::replace(&mut self.document, DynamicImage::new_rgb8(1, 1)),
|
|
||||||
rotation_to_apply,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set to standard rotation mode
|
|
||||||
self.transform.rotation = RotationMode::Standard(rotation);
|
|
||||||
self.handle = Self::create_image_handle_from_image(&self.document);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flip(&mut self, direction: FlipDirection) {
|
|
||||||
self.document = Self::apply_flip(
|
|
||||||
std::mem::replace(&mut self.document, DynamicImage::new_rgb8(1, 1)),
|
|
||||||
direction,
|
|
||||||
);
|
|
||||||
match direction {
|
|
||||||
FlipDirection::Horizontal => self.transform.flip_h = !self.transform.flip_h,
|
|
||||||
FlipDirection::Vertical => self.transform.flip_v = !self.transform.flip_v,
|
|
||||||
}
|
|
||||||
self.handle = Self::create_image_handle_from_image(&self.document);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn transform_state(&self) -> TransformState {
|
|
||||||
self.transform
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rotate_fine(&mut self, angle_degrees: f32) {
|
|
||||||
// TODO: Re-enable when imageproc dependency is added to Cargo.toml
|
|
||||||
// For now, round to nearest 90-degree rotation
|
|
||||||
log::warn!("Fine rotation not yet implemented, rounding to nearest 90 degrees");
|
|
||||||
|
|
||||||
let rounded = ((angle_degrees / 90.0).round() as i16 * 90) % 360;
|
|
||||||
let rotation = match rounded {
|
|
||||||
0 => Rotation::None,
|
|
||||||
90 => Rotation::Cw90,
|
|
||||||
180 => Rotation::Cw180,
|
|
||||||
270 => Rotation::Cw270,
|
|
||||||
_ => Rotation::None,
|
|
||||||
};
|
|
||||||
|
|
||||||
self.rotate(rotation);
|
|
||||||
self.transform.rotation = RotationMode::Standard(rotation);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reset_fine_rotation(&mut self) {
|
|
||||||
self.fine_rotation_angle = 0.0;
|
|
||||||
self.transform.rotation = RotationMode::Standard(Rotation::None);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_interpolation_quality(&mut self, quality: InterpolationQuality) {
|
|
||||||
self.interpolation_quality = quality;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,334 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/domain/document/types/vector.rs
|
|
||||||
//
|
|
||||||
// Vector documents (SVG, etc.).
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
/// Minimum pixmap size for SVG rendering (prevents zero-size pixmaps).
|
|
||||||
const MIN_PIXMAP_SIZE: u32 = 1;
|
|
||||||
|
|
||||||
use image::{DynamicImage, GenericImageView, RgbaImage};
|
|
||||||
use resvg::tiny_skia::{self, Pixmap};
|
|
||||||
use resvg::usvg::{Options, Tree};
|
|
||||||
|
|
||||||
use cosmic::widget::image::Handle as ImageHandle;
|
|
||||||
|
|
||||||
use crate::domain::document::core::document::{
|
|
||||||
DocResult, DocumentInfo, FlipDirection, Renderable, RenderOutput, Rotation, RotationMode,
|
|
||||||
TransformState, Transformable,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Represents a vector document such as SVG.
|
|
||||||
pub struct VectorDocument {
|
|
||||||
/// Parsed SVG document for re-rendering at different scales.
|
|
||||||
document: Tree,
|
|
||||||
/// Native width of the SVG (from viewBox or width attribute).
|
|
||||||
native_width: u32,
|
|
||||||
/// Native height of the SVG (from viewBox or height attribute).
|
|
||||||
native_height: u32,
|
|
||||||
/// Current render scale (1.0 = native size).
|
|
||||||
current_scale: f64,
|
|
||||||
/// Accumulated transformations.
|
|
||||||
transform: TransformState,
|
|
||||||
/// Rasterized image at the current scale.
|
|
||||||
pub rendered: DynamicImage,
|
|
||||||
/// Image handle for display.
|
|
||||||
pub handle: ImageHandle,
|
|
||||||
/// Current rendered width.
|
|
||||||
pub width: u32,
|
|
||||||
/// Current rendered height.
|
|
||||||
pub height: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VectorDocument {
|
|
||||||
/// Load a vector document from disk.
|
|
||||||
pub fn open(path: &Path) -> anyhow::Result<Self> {
|
|
||||||
let raw_data = std::fs::read_to_string(path)?;
|
|
||||||
|
|
||||||
// Parse SVG with default options.
|
|
||||||
let options = Options::default();
|
|
||||||
let document = Tree::from_str(&raw_data, &options)?;
|
|
||||||
|
|
||||||
// Get native size from the parsed document.
|
|
||||||
let size = document.size();
|
|
||||||
let native_width = size.width().ceil() as u32;
|
|
||||||
let native_height = size.height().ceil() as u32;
|
|
||||||
|
|
||||||
let transform = TransformState::default();
|
|
||||||
|
|
||||||
// Render at native scale (1.0).
|
|
||||||
let (rendered, width, height) =
|
|
||||||
render_document(&document, native_width, native_height, 1.0, transform)?;
|
|
||||||
let handle = Self::create_image_handle_from_image(&rendered);
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
document,
|
|
||||||
native_width,
|
|
||||||
native_height,
|
|
||||||
current_scale: 1.0,
|
|
||||||
transform,
|
|
||||||
rendered,
|
|
||||||
handle,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the dimensions of the rasterized representation.
|
|
||||||
#[must_use]
|
|
||||||
pub fn dimensions(&self) -> (u32, u32) {
|
|
||||||
(self.width, self.height)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current image handle.
|
|
||||||
#[must_use]
|
|
||||||
pub fn handle(&self) -> ImageHandle {
|
|
||||||
self.handle.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get native dimensions (before transformations).
|
|
||||||
#[must_use]
|
|
||||||
pub fn native_dimensions(&self) -> (u32, u32) {
|
|
||||||
(self.native_width, self.native_height)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract metadata for this vector document.
|
|
||||||
pub fn extract_meta(
|
|
||||||
&self,
|
|
||||||
path: &Path,
|
|
||||||
) -> crate::domain::document::core::metadata::DocumentMeta {
|
|
||||||
use crate::domain::document::core::metadata::{BasicMeta, DocumentMeta};
|
|
||||||
|
|
||||||
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 basic = BasicMeta {
|
|
||||||
file_name,
|
|
||||||
file_path,
|
|
||||||
format: "SVG".to_string(),
|
|
||||||
width: self.native_width,
|
|
||||||
height: self.native_height,
|
|
||||||
file_size,
|
|
||||||
color_type: "Vector".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
DocumentMeta { basic, exif: None }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Crop the document to the specified rectangle.
|
|
||||||
/// Works on rendered output (raster).
|
|
||||||
pub fn crop(&mut self, x: u32, y: u32, width: u32, height: u32) -> Result<(), String> {
|
|
||||||
let (img_width, img_height) = self.rendered.dimensions();
|
|
||||||
|
|
||||||
// Validate crop region
|
|
||||||
if x >= img_width || y >= img_height {
|
|
||||||
return Err(format!(
|
|
||||||
"Crop region ({}, {}) is outside rendered bounds ({}, {})",
|
|
||||||
x, y, img_width, img_height
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clamp dimensions
|
|
||||||
let crop_width = width.min(img_width - x);
|
|
||||||
let crop_height = height.min(img_height - y);
|
|
||||||
|
|
||||||
if crop_width == 0 || crop_height == 0 {
|
|
||||||
return Err("Crop region has zero width or height".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Crop rendered image
|
|
||||||
self.rendered = self.rendered.crop_imm(x, y, crop_width, crop_height);
|
|
||||||
self.width = crop_width;
|
|
||||||
self.height = crop_height;
|
|
||||||
|
|
||||||
// Update handle
|
|
||||||
self.handle = Self::create_image_handle_from_image(&self.rendered);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Re-render the SVG at a new scale, preserving transformations.
|
|
||||||
/// Returns true if re-rendering occurred.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn render_at_scale(&mut self, scale: f64) -> bool {
|
|
||||||
// Skip if scale hasn't changed
|
|
||||||
if (self.current_scale - scale).abs() < f64::EPSILON {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
match render_document(
|
|
||||||
&self.document,
|
|
||||||
self.native_width,
|
|
||||||
self.native_height,
|
|
||||||
scale,
|
|
||||||
self.transform,
|
|
||||||
) {
|
|
||||||
Ok((rendered, width, height)) => {
|
|
||||||
self.current_scale = scale;
|
|
||||||
self.rendered = rendered;
|
|
||||||
self.width = width;
|
|
||||||
self.height = height;
|
|
||||||
self.handle = Self::create_image_handle_from_image(&self.rendered);
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to re-render SVG at scale {scale}: {e}");
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Re-render with current scale and transform.
|
|
||||||
fn rerender(&mut self) {
|
|
||||||
if let Ok((rendered, width, height)) = render_document(
|
|
||||||
&self.document,
|
|
||||||
self.native_width,
|
|
||||||
self.native_height,
|
|
||||||
self.current_scale,
|
|
||||||
self.transform,
|
|
||||||
) {
|
|
||||||
self.rendered = rendered;
|
|
||||||
self.width = width;
|
|
||||||
self.height = height;
|
|
||||||
self.handle = Self::create_image_handle_from_image(&self.rendered);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function
|
|
||||||
fn create_image_handle_from_image(img: &image::DynamicImage) -> ImageHandle {
|
|
||||||
let (width, height) = img.dimensions();
|
|
||||||
let pixels = img.to_rgba8().into_raw();
|
|
||||||
ImageHandle::from_rgba(width, height, pixels)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Trait Implementations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
impl Renderable for VectorDocument {
|
|
||||||
fn render(&mut self, scale: f64) -> DocResult<RenderOutput> {
|
|
||||||
self.render_at_scale(scale);
|
|
||||||
Ok(RenderOutput {
|
|
||||||
handle: self.handle.clone(),
|
|
||||||
width: self.width,
|
|
||||||
height: self.height,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn info(&self) -> DocumentInfo {
|
|
||||||
DocumentInfo {
|
|
||||||
width: self.native_width,
|
|
||||||
height: self.native_height,
|
|
||||||
format: "SVG".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Transformable for VectorDocument {
|
|
||||||
fn rotate(&mut self, rotation: Rotation) {
|
|
||||||
self.transform.rotation = RotationMode::Standard(rotation);
|
|
||||||
self.rerender();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flip(&mut self, direction: FlipDirection) {
|
|
||||||
match direction {
|
|
||||||
FlipDirection::Horizontal => self.transform.flip_h = !self.transform.flip_h,
|
|
||||||
FlipDirection::Vertical => self.transform.flip_v = !self.transform.flip_v,
|
|
||||||
}
|
|
||||||
self.rerender();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn transform_state(&self) -> TransformState {
|
|
||||||
self.transform
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render the SVG document at a given scale with transformations.
|
|
||||||
fn render_document(
|
|
||||||
document: &Tree,
|
|
||||||
native_width: u32,
|
|
||||||
native_height: u32,
|
|
||||||
scale: f64,
|
|
||||||
transform: TransformState,
|
|
||||||
) -> anyhow::Result<(DynamicImage, u32, u32)> {
|
|
||||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
|
||||||
let width = ((f64::from(native_width) * scale).ceil() as u32).max(MIN_PIXMAP_SIZE);
|
|
||||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
|
||||||
let height = ((f64::from(native_height) * scale).ceil() as u32).max(MIN_PIXMAP_SIZE);
|
|
||||||
|
|
||||||
let mut pixmap =
|
|
||||||
Pixmap::new(width, height).ok_or_else(|| anyhow::anyhow!("Failed to create pixmap"))?;
|
|
||||||
|
|
||||||
#[allow(clippy::cast_possible_truncation)]
|
|
||||||
let scale_f32 = scale as f32;
|
|
||||||
let ts = tiny_skia::Transform::from_scale(scale_f32, scale_f32);
|
|
||||||
resvg::render(document, ts, &mut pixmap.as_mut());
|
|
||||||
|
|
||||||
let mut image = pixmap_to_dynamic_image(&pixmap);
|
|
||||||
|
|
||||||
// Apply flip transformations using shared utilities
|
|
||||||
if transform.flip_h {
|
|
||||||
image = crate::domain::document::operations::transform::apply_flip(
|
|
||||||
image,
|
|
||||||
FlipDirection::Horizontal,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if transform.flip_v {
|
|
||||||
image = crate::domain::document::operations::transform::apply_flip(
|
|
||||||
image,
|
|
||||||
FlipDirection::Vertical,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply rotation using shared utilities
|
|
||||||
image = match transform.rotation {
|
|
||||||
RotationMode::Standard(rotation) => {
|
|
||||||
crate::domain::document::operations::transform::apply_rotation(image, rotation)
|
|
||||||
}
|
|
||||||
RotationMode::Fine(_) => {
|
|
||||||
// For vector documents, fine rotation is handled differently
|
|
||||||
// For now, we just render without rotation
|
|
||||||
// TODO: Implement fine rotation support for vector documents
|
|
||||||
image
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let final_width = image.width();
|
|
||||||
let final_height = image.height();
|
|
||||||
|
|
||||||
Ok((image, final_width, final_height))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert a `tiny_skia` Pixmap to a `DynamicImage`.
|
|
||||||
fn pixmap_to_dynamic_image(pixmap: &Pixmap) -> DynamicImage {
|
|
||||||
let width = pixmap.width();
|
|
||||||
let height = pixmap.height();
|
|
||||||
|
|
||||||
// tiny_skia uses premultiplied alpha, we need to unpremultiply for image crate
|
|
||||||
let mut pixels = Vec::with_capacity((width * height * 4) as usize);
|
|
||||||
for pixel in pixmap.pixels() {
|
|
||||||
let a = pixel.alpha();
|
|
||||||
if a == 0 {
|
|
||||||
pixels.extend_from_slice(&[0, 0, 0, 0]);
|
|
||||||
} else {
|
|
||||||
// Unpremultiply: color = premultiplied_color * 255 / alpha
|
|
||||||
let r = (u16::from(pixel.red()) * 255 / u16::from(a)) as u8;
|
|
||||||
let g = (u16::from(pixel.green()) * 255 / u16::from(a)) as u8;
|
|
||||||
let b = (u16::from(pixel.blue()) * 255 / u16::from(a)) as u8;
|
|
||||||
pixels.extend_from_slice(&[r, g, b, a]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let rgba_image = RgbaImage::from_raw(width, height, pixels)
|
|
||||||
.expect("Failed to create RgbaImage from pixmap data");
|
|
||||||
|
|
||||||
DynamicImage::ImageRgba8(rgba_image)
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/domain/mod.rs
|
|
||||||
//
|
|
||||||
// Domain layer: business logic, document abstractions, and viewport management.
|
|
||||||
|
|
||||||
pub mod document;
|
|
||||||
|
|
||||||
// Re-export core document types
|
|
||||||
#[allow(unused_imports)]
|
|
||||||
pub use document::core::content::DocumentContent;
|
|
||||||
#[allow(unused_imports)]
|
|
||||||
pub use document::core::metadata::DocumentMeta;
|
|
||||||
|
|
||||||
// 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 for all application and UI code.
|
|
||||||
46
src/error.rs
Normal file
46
src/error.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// src/error.rs
|
||||||
|
//
|
||||||
|
// Central error and result types for Noctua.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Error {
|
||||||
|
/// A requested file or resource was not found.
|
||||||
|
NotFound(String),
|
||||||
|
/// The file format or operation is not supported.
|
||||||
|
Unsupported(String),
|
||||||
|
/// A session could not be loaded or saved.
|
||||||
|
Session(String),
|
||||||
|
/// An underlying I/O error.
|
||||||
|
Io(std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Error::NotFound(msg) => write!(f, "Not found: {msg}"),
|
||||||
|
Error::Unsupported(msg) => write!(f, "Unsupported: {msg}"),
|
||||||
|
Error::Session(msg) => write!(f, "Session error: {msg}"),
|
||||||
|
Error::Io(err) => write!(f, "I/O error: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for Error {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Error::Io(err) => Some(err),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for Error {
|
||||||
|
fn from(err: std::io::Error) -> Self {
|
||||||
|
Error::Io(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/i18n.rs
55
src/i18n.rs
|
|
@ -1,55 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/i18n.rs
|
|
||||||
//
|
|
||||||
// Internationalization (i18n) support.
|
|
||||||
|
|
||||||
use i18n_embed::{
|
|
||||||
DefaultLocalizer, LanguageLoader, Localizer,
|
|
||||||
fluent::{FluentLanguageLoader, fluent_language_loader},
|
|
||||||
unic_langid::LanguageIdentifier,
|
|
||||||
};
|
|
||||||
use rust_embed::RustEmbed;
|
|
||||||
use std::sync::LazyLock;
|
|
||||||
|
|
||||||
/// Applies the requested language(s) to requested translations from the `fl!()` macro.
|
|
||||||
pub fn init(requested_languages: &[LanguageIdentifier]) {
|
|
||||||
if let Err(why) = localizer().select(requested_languages) {
|
|
||||||
println!("error while loading fluent localizations: {why}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the `Localizer` to be used for localizing this library.
|
|
||||||
#[must_use]
|
|
||||||
pub fn localizer() -> Box<dyn Localizer> {
|
|
||||||
Box::from(DefaultLocalizer::new(&*LANGUAGE_LOADER, &Localizations))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(RustEmbed)]
|
|
||||||
#[folder = "i18n/"]
|
|
||||||
struct Localizations;
|
|
||||||
|
|
||||||
pub static LANGUAGE_LOADER: LazyLock<FluentLanguageLoader> = LazyLock::new(|| {
|
|
||||||
let loader: FluentLanguageLoader = fluent_language_loader!();
|
|
||||||
|
|
||||||
loader
|
|
||||||
.load_fallback_language(&Localizations)
|
|
||||||
.expect("Error while loading fallback language");
|
|
||||||
|
|
||||||
loader
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Request a localized string by ID from the i18n/ directory.
|
|
||||||
#[macro_export]
|
|
||||||
macro_rules! fl {
|
|
||||||
($message_id:literal) => {{
|
|
||||||
i18n_embed_fl::fl!($crate::i18n::LANGUAGE_LOADER, $message_id)
|
|
||||||
}};
|
|
||||||
|
|
||||||
($message_id:literal, $($name:ident: $value:expr),*) => {{
|
|
||||||
let mut args = std::collections::HashMap::new();
|
|
||||||
$(
|
|
||||||
args.insert(stringify!($name), $value.to_string());
|
|
||||||
)*
|
|
||||||
i18n_embed_fl::fl!($crate::i18n::LANGUAGE_LOADER, $message_id, args)
|
|
||||||
}};
|
|
||||||
}
|
|
||||||
9
src/infrastructure/cache/mod.rs
vendored
9
src/infrastructure/cache/mod.rs
vendored
|
|
@ -1,9 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/infrastructure/cache/mod.rs
|
|
||||||
//
|
|
||||||
// Cache infrastructure: thumbnail and document caching.
|
|
||||||
|
|
||||||
pub mod thumbnail_cache;
|
|
||||||
|
|
||||||
// Re-export ThumbnailCache
|
|
||||||
pub use thumbnail_cache::ThumbnailCache;
|
|
||||||
149
src/infrastructure/cache/thumbnail_cache.rs
vendored
149
src/infrastructure/cache/thumbnail_cache.rs
vendored
|
|
@ -1,149 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/infrastructure/cache/thumbnail_cache.rs
|
|
||||||
//
|
|
||||||
// Disk cache for document thumbnails stored in ~/.cache/noctua/
|
|
||||||
|
|
||||||
use std::fs;
|
|
||||||
use std::io::BufWriter;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use image::DynamicImage;
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
use cosmic::widget::image::Handle as ImageHandle;
|
|
||||||
|
|
||||||
use crate::domain::document::operations::render::create_image_handle_from_image;
|
|
||||||
|
|
||||||
/// Cache directory name under ~/.cache/ for thumbnail storage.
|
|
||||||
const CACHE_DIR: &str = "noctua";
|
|
||||||
|
|
||||||
/// File extension for cached thumbnails.
|
|
||||||
const THUMBNAIL_EXT: &str = "png";
|
|
||||||
|
|
||||||
/// Thumbnail cache manager for disk-based caching.
|
|
||||||
pub struct ThumbnailCache;
|
|
||||||
|
|
||||||
impl ThumbnailCache {
|
|
||||||
/// Load a thumbnail from disk cache.
|
|
||||||
/// Returns None if not cached or cache is invalid.
|
|
||||||
pub fn load(file_path: &Path, page: usize) -> Option<ImageHandle> {
|
|
||||||
let cache_path = Self::thumbnail_path(file_path, page)?;
|
|
||||||
|
|
||||||
log::debug!("Cache lookup: file={}, page={}", file_path.display(), page);
|
|
||||||
|
|
||||||
if !cache_path.exists() {
|
|
||||||
log::debug!(
|
|
||||||
"Thumbnail not found in cache: file={} page={}",
|
|
||||||
file_path.display(),
|
|
||||||
page
|
|
||||||
);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let img = image::open(&cache_path).ok()?;
|
|
||||||
log::debug!(
|
|
||||||
"Thumbnail loaded from cache: file={} page={}",
|
|
||||||
file_path.display(),
|
|
||||||
page
|
|
||||||
);
|
|
||||||
Some(create_image_handle_from_image(&img))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save a thumbnail to disk cache.
|
|
||||||
pub fn save(file_path: &Path, page: usize, image: &DynamicImage) -> Option<()> {
|
|
||||||
let dir = Self::ensure_cache_dir()?;
|
|
||||||
let key = Self::cache_key(file_path, page)?;
|
|
||||||
let cache_path = dir.join(format!("{key}.{THUMBNAIL_EXT}"));
|
|
||||||
|
|
||||||
log::debug!(
|
|
||||||
"Saving thumbnail to cache: file={}, page={}, path={}",
|
|
||||||
file_path.display(),
|
|
||||||
page,
|
|
||||||
cache_path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
let file = fs::File::create(&cache_path).ok()?;
|
|
||||||
let writer = BufWriter::new(file);
|
|
||||||
|
|
||||||
let res = image.write_to(
|
|
||||||
&mut std::io::BufWriter::new(writer),
|
|
||||||
image::ImageFormat::Png,
|
|
||||||
);
|
|
||||||
match res {
|
|
||||||
Ok(()) => {
|
|
||||||
log::debug!(
|
|
||||||
"Thumbnail cached successfully: file={} page={}",
|
|
||||||
file_path.display(),
|
|
||||||
page
|
|
||||||
);
|
|
||||||
Some(())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!(
|
|
||||||
"Failed to cache thumbnail: file={} page={}: {}",
|
|
||||||
file_path.display(),
|
|
||||||
page,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all cached thumbnails.
|
|
||||||
pub fn clear_cache() -> std::io::Result<()> {
|
|
||||||
if let Some(dir) = Self::cache_dir()
|
|
||||||
&& dir.exists()
|
|
||||||
{
|
|
||||||
fs::remove_dir_all(&dir)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a thumbnail exists in cache.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn has(file_path: &Path, page: usize) -> bool {
|
|
||||||
Self::thumbnail_path(file_path, page).is_some_and(|p| p.exists())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Private helper methods
|
|
||||||
|
|
||||||
/// Get the cache directory path (~/.cache/noctua/).
|
|
||||||
fn cache_dir() -> Option<PathBuf> {
|
|
||||||
dirs::cache_dir().map(|p| p.join(CACHE_DIR))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ensure the cache directory exists.
|
|
||||||
fn ensure_cache_dir() -> Option<PathBuf> {
|
|
||||||
let dir = Self::cache_dir()?;
|
|
||||||
fs::create_dir_all(&dir).ok()?;
|
|
||||||
Some(dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate a cache key from file path, modification time, and page number.
|
|
||||||
/// Format: sha256(path + mtime + page)
|
|
||||||
fn cache_key(file_path: &Path, page: usize) -> Option<String> {
|
|
||||||
let metadata = fs::metadata(file_path).ok()?;
|
|
||||||
let mtime = metadata
|
|
||||||
.modified()
|
|
||||||
.ok()?
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.ok()?
|
|
||||||
.as_secs();
|
|
||||||
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(file_path.to_string_lossy().as_bytes());
|
|
||||||
hasher.update(mtime.to_le_bytes());
|
|
||||||
hasher.update(page.to_le_bytes());
|
|
||||||
|
|
||||||
let hash = hasher.finalize();
|
|
||||||
Some(format!("{hash:x}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the full path for a cached thumbnail.
|
|
||||||
fn thumbnail_path(file_path: &Path, page: usize) -> Option<PathBuf> {
|
|
||||||
let dir = Self::cache_dir()?;
|
|
||||||
let key = Self::cache_key(file_path, page)?;
|
|
||||||
Some(dir.join(format!("{key}.{THUMBNAIL_EXT}")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/infrastructure/filesystem/file_ops.rs
|
|
||||||
//
|
|
||||||
// File system operations for document handling.
|
|
||||||
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use anyhow::anyhow;
|
|
||||||
|
|
||||||
use crate::domain::document::core::content::{DocumentContent, DocumentKind};
|
|
||||||
|
|
||||||
use crate::domain::document::types::raster::RasterDocument;
|
|
||||||
#[cfg(feature = "vector")]
|
|
||||||
use crate::domain::document::types::vector::VectorDocument;
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
use crate::domain::document::types::portable::PortableDocument;
|
|
||||||
|
|
||||||
/// Open a document from a file path and dispatch to the correct type.
|
|
||||||
///
|
|
||||||
/// Raster formats are delegated to the `image` crate, which decides
|
|
||||||
/// based on enabled codecs (e.g. default-formats).
|
|
||||||
pub fn open_document(path: &Path) -> anyhow::Result<DocumentContent> {
|
|
||||||
let kind = DocumentKind::from_path(path)
|
|
||||||
.ok_or_else(|| anyhow!("Unsupported document type: {}", path.display()))?;
|
|
||||||
|
|
||||||
let content = match kind {
|
|
||||||
DocumentKind::Raster => {
|
|
||||||
let raster = RasterDocument::open(path)?;
|
|
||||||
DocumentContent::Raster(raster)
|
|
||||||
}
|
|
||||||
#[cfg(feature = "vector")]
|
|
||||||
DocumentKind::Vector => {
|
|
||||||
let vector = VectorDocument::open(path)?;
|
|
||||||
DocumentContent::Vector(vector)
|
|
||||||
}
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
DocumentKind::Portable => {
|
|
||||||
let portable = PortableDocument::open(path)?;
|
|
||||||
DocumentContent::Portable(portable)
|
|
||||||
}
|
|
||||||
#[cfg(not(any(feature = "vector", feature = "portable")))]
|
|
||||||
_ => return Err(anyhow!("No document features enabled")),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Collect all supported document files from a directory, sorted alphabetically.
|
|
||||||
///
|
|
||||||
/// This scans the directory and returns a list of files that are recognized as
|
|
||||||
/// supported document types (images, PDFs, SVGs, etc.).
|
|
||||||
pub fn collect_supported_files(dir: &Path) -> Vec<PathBuf> {
|
|
||||||
let mut entries: Vec<PathBuf> = Vec::new();
|
|
||||||
|
|
||||||
if let Ok(read_dir) = fs::read_dir(dir) {
|
|
||||||
for entry in read_dir.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
|
|
||||||
// Only keep regular files that are recognized as supported documents.
|
|
||||||
if path.is_file() && DocumentKind::from_path(&path).is_some() {
|
|
||||||
entries.push(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.sort();
|
|
||||||
entries
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// File metadata helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Retrieve the file size in bytes. Returns 0 if the file cannot be accessed.
|
|
||||||
pub fn file_size(path: &Path) -> u64 {
|
|
||||||
fs::metadata(path).map(|m| m.len()).unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read raw bytes from a file for metadata extraction (e.g., EXIF).
|
|
||||||
/// Returns None if the file cannot be read.
|
|
||||||
pub fn read_file_bytes(path: &Path) -> Option<Vec<u8>> {
|
|
||||||
fs::read(path).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// DEPRECATED FUNCTIONS
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// The following functions have been replaced by DocumentManager and are
|
|
||||||
// commented out to avoid AppModel dependencies.
|
|
||||||
//
|
|
||||||
// Instead of using these functions directly, use:
|
|
||||||
// - DocumentManager::open_document() for opening files
|
|
||||||
// - DocumentManager::next_document() / previous_document() for navigation
|
|
||||||
// - Application commands for operations like crop, save, etc.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/*
|
|
||||||
/// Open the initial path passed on the command line.
|
|
||||||
///
|
|
||||||
/// DEPRECATED: Use DocumentManager::open_document() instead.
|
|
||||||
pub fn open_initial_path(model: &mut AppModel, path: &PathBuf) {
|
|
||||||
if path.is_dir() {
|
|
||||||
open_from_directory(model, path);
|
|
||||||
} else {
|
|
||||||
open_single_file(model, path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open the first supported document from the given directory.
|
|
||||||
///
|
|
||||||
/// DEPRECATED: Use DocumentManager::open_document() instead.
|
|
||||||
pub fn open_from_directory(model: &mut AppModel, dir: &Path) {
|
|
||||||
let entries = collect_supported_files(dir);
|
|
||||||
|
|
||||||
if entries.is_empty() {
|
|
||||||
model.set_error(format!(
|
|
||||||
"No supported documents found in directory: {}",
|
|
||||||
dir.display()
|
|
||||||
));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let first = entries[0].clone();
|
|
||||||
model.folder_entries = entries;
|
|
||||||
model.current_index = Some(0);
|
|
||||||
|
|
||||||
load_document_into_model(model, &first);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open a single file.
|
|
||||||
///
|
|
||||||
/// DEPRECATED: Use DocumentManager::open_document() instead.
|
|
||||||
pub fn open_single_file(model: &mut AppModel, path: &Path) {
|
|
||||||
load_document_into_model(model, path);
|
|
||||||
|
|
||||||
if model.document.is_some()
|
|
||||||
&& let Some(parent) = path.parent()
|
|
||||||
{
|
|
||||||
refresh_folder_entries(model, parent, path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load a document into the model.
|
|
||||||
///
|
|
||||||
/// DEPRECATED: Use DocumentManager methods instead.
|
|
||||||
fn load_document_into_model(model: &mut AppModel, path: &Path) {
|
|
||||||
// Implementation omitted - use DocumentManager instead
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Refresh folder entries.
|
|
||||||
///
|
|
||||||
/// DEPRECATED: DocumentManager handles this automatically.
|
|
||||||
pub fn refresh_folder_entries(model: &mut AppModel, folder: &Path, current: &Path) {
|
|
||||||
// Implementation omitted - use DocumentManager instead
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Navigate to the next document.
|
|
||||||
///
|
|
||||||
/// DEPRECATED: Use DocumentManager::next_document() instead.
|
|
||||||
pub fn navigate_next(model: &mut AppModel) {
|
|
||||||
// Implementation omitted - use DocumentManager instead
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Navigate to the previous document.
|
|
||||||
///
|
|
||||||
/// DEPRECATED: Use DocumentManager::previous_document() instead.
|
|
||||||
pub fn navigate_prev(model: &mut AppModel) {
|
|
||||||
// Implementation omitted - use DocumentManager instead
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply crop operation.
|
|
||||||
///
|
|
||||||
/// DEPRECATED: Use CropDocumentCommand instead.
|
|
||||||
pub fn apply_crop(
|
|
||||||
crop_selection: &CropSelection,
|
|
||||||
doc: &DocumentContent,
|
|
||||||
current_path: &Path,
|
|
||||||
canvas_size: cosmic::iced::Size,
|
|
||||||
image_size: cosmic::iced::Size,
|
|
||||||
scale: f32,
|
|
||||||
pan_x: f32,
|
|
||||||
pan_y: f32,
|
|
||||||
view_mode: &ViewMode,
|
|
||||||
) -> Result<PathBuf, String> {
|
|
||||||
// Implementation omitted - use CropDocumentCommand instead
|
|
||||||
Err("Deprecated function - use CropDocumentCommand".to_string())
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/infrastructure/filesystem/mod.rs
|
|
||||||
//
|
|
||||||
// Filesystem operations: file I/O, folder scanning, and file watching.
|
|
||||||
|
|
||||||
pub mod file_ops;
|
|
||||||
|
|
||||||
// TODO: Re-implement these helpers without UI dependencies
|
|
||||||
// pub use file_ops::{file_size, read_file_bytes};
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/infrastructure/loaders/document_loader.rs
|
|
||||||
//
|
|
||||||
// Document loader trait and factory for loading documents from files.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use crate::domain::document::core::content::{DocumentContent, DocumentKind};
|
|
||||||
use crate::domain::document::core::document::DocResult;
|
|
||||||
|
|
||||||
use super::raster_loader::RasterLoader;
|
|
||||||
#[cfg(feature = "vector")]
|
|
||||||
use super::svg_loader::SvgLoader;
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
use super::pdf_loader::PdfLoader;
|
|
||||||
|
|
||||||
/// Trait for loading documents from files.
|
|
||||||
///
|
|
||||||
/// Implementations handle specific document formats (raster, vector, portable).
|
|
||||||
pub trait DocumentLoader {
|
|
||||||
/// Load a document from a file path.
|
|
||||||
fn load(&self, path: &Path) -> DocResult<DocumentContent>;
|
|
||||||
|
|
||||||
/// Check if this loader supports the given file.
|
|
||||||
fn supports(&self, path: &Path) -> bool;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Document loader factory.
|
|
||||||
///
|
|
||||||
/// Detects the document format and delegates to the appropriate loader.
|
|
||||||
pub struct DocumentLoaderFactory;
|
|
||||||
|
|
||||||
impl DocumentLoaderFactory {
|
|
||||||
/// Create a new document loader factory.
|
|
||||||
#[must_use]
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load a document from a file, automatically detecting the format.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns an error if:
|
|
||||||
/// - The file format is not supported
|
|
||||||
/// - The file cannot be read
|
|
||||||
/// - The document is malformed
|
|
||||||
pub fn load(&self, path: &Path) -> DocResult<DocumentContent> {
|
|
||||||
let kind = DocumentKind::from_path(path).ok_or_else(|| {
|
|
||||||
anyhow::anyhow!(
|
|
||||||
"Unsupported file format: {}",
|
|
||||||
path.extension()
|
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.unwrap_or("unknown")
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
match kind {
|
|
||||||
DocumentKind::Raster => {
|
|
||||||
let loader = RasterLoader;
|
|
||||||
loader.load(path)
|
|
||||||
}
|
|
||||||
#[cfg(feature = "vector")]
|
|
||||||
DocumentKind::Vector => {
|
|
||||||
let loader = SvgLoader;
|
|
||||||
loader.load(path)
|
|
||||||
}
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
DocumentKind::Portable => {
|
|
||||||
let loader = PdfLoader;
|
|
||||||
loader.load(path)
|
|
||||||
}
|
|
||||||
#[cfg(not(any(feature = "vector", feature = "portable")))]
|
|
||||||
_ => Err(anyhow::anyhow!(
|
|
||||||
"No document loaders available (check feature flags)"
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Detect the document kind from a file path.
|
|
||||||
#[must_use]
|
|
||||||
pub fn detect_kind(&self, path: &Path) -> Option<DocumentKind> {
|
|
||||||
DocumentKind::from_path(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a file is supported by any loader.
|
|
||||||
#[must_use]
|
|
||||||
pub fn is_supported(&self, path: &Path) -> bool {
|
|
||||||
DocumentKind::from_path(path).is_some()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for DocumentLoaderFactory {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_factory_creation() {
|
|
||||||
let factory = DocumentLoaderFactory::new();
|
|
||||||
assert!(std::ptr::eq(&factory, &factory)); // Just a dummy test
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_detect_kind() {
|
|
||||||
let factory = DocumentLoaderFactory::new();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
factory.detect_kind(Path::new("test.png")),
|
|
||||||
Some(DocumentKind::Raster)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
factory.detect_kind(Path::new("test.jpg")),
|
|
||||||
Some(DocumentKind::Raster)
|
|
||||||
);
|
|
||||||
|
|
||||||
#[cfg(feature = "vector")]
|
|
||||||
{
|
|
||||||
assert_eq!(
|
|
||||||
factory.detect_kind(Path::new("test.svg")),
|
|
||||||
Some(DocumentKind::Vector)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
{
|
|
||||||
assert_eq!(
|
|
||||||
factory.detect_kind(Path::new("test.pdf")),
|
|
||||||
Some(DocumentKind::Portable)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(factory.detect_kind(Path::new("test.txt")), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_is_supported() {
|
|
||||||
let factory = DocumentLoaderFactory::new();
|
|
||||||
|
|
||||||
assert!(factory.is_supported(Path::new("test.png")));
|
|
||||||
assert!(!factory.is_supported(Path::new("test.txt")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/infrastructure/loaders/mod.rs
|
|
||||||
//
|
|
||||||
// Document loaders for various formats.
|
|
||||||
|
|
||||||
pub mod document_loader;
|
|
||||||
|
|
||||||
pub mod raster_loader;
|
|
||||||
#[cfg(feature = "vector")]
|
|
||||||
pub mod svg_loader;
|
|
||||||
#[cfg(feature = "portable")]
|
|
||||||
pub mod pdf_loader;
|
|
||||||
|
|
||||||
// Re-export main types
|
|
||||||
pub use document_loader::DocumentLoaderFactory;
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/infrastructure/loaders/pdf_loader.rs
|
|
||||||
//
|
|
||||||
// Loader for PDF portable documents.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use crate::domain::document::core::content::DocumentContent;
|
|
||||||
use crate::domain::document::core::document::DocResult;
|
|
||||||
use crate::domain::document::types::portable::PortableDocument;
|
|
||||||
use crate::infrastructure::loaders::document_loader::DocumentLoader;
|
|
||||||
|
|
||||||
/// Loader for PDF portable documents.
|
|
||||||
pub struct PdfLoader;
|
|
||||||
|
|
||||||
impl DocumentLoader for PdfLoader {
|
|
||||||
fn load(&self, path: &Path) -> DocResult<DocumentContent> {
|
|
||||||
let document = PortableDocument::open(path)
|
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to load PDF document: {e}"))?;
|
|
||||||
|
|
||||||
Ok(DocumentContent::Portable(document))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supports(&self, path: &Path) -> bool {
|
|
||||||
if let Some(ext) = path.extension() {
|
|
||||||
let ext_str = ext.to_string_lossy().to_lowercase();
|
|
||||||
ext_str == "pdf"
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_supports() {
|
|
||||||
let loader = PdfLoader;
|
|
||||||
|
|
||||||
assert!(loader.supports(Path::new("test.pdf")));
|
|
||||||
assert!(loader.supports(Path::new("test.PDF")));
|
|
||||||
assert!(loader.supports(Path::new("document.pdf")));
|
|
||||||
assert!(!loader.supports(Path::new("test.png")));
|
|
||||||
assert!(!loader.supports(Path::new("test.svg")));
|
|
||||||
assert!(!loader.supports(Path::new("test.jpg")));
|
|
||||||
assert!(!loader.supports(Path::new("test.txt")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/infrastructure/loaders/raster_loader.rs
|
|
||||||
//
|
|
||||||
// Loader for raster image documents (PNG, JPEG, WebP, etc.).
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use crate::domain::document::core::content::DocumentContent;
|
|
||||||
use crate::domain::document::core::document::DocResult;
|
|
||||||
use crate::domain::document::types::raster::RasterDocument;
|
|
||||||
use crate::infrastructure::loaders::document_loader::DocumentLoader;
|
|
||||||
|
|
||||||
/// Loader for raster image documents.
|
|
||||||
pub struct RasterLoader;
|
|
||||||
|
|
||||||
impl DocumentLoader for RasterLoader {
|
|
||||||
fn load(&self, path: &Path) -> DocResult<DocumentContent> {
|
|
||||||
let document = RasterDocument::open(path)
|
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to load raster document: {e}"))?;
|
|
||||||
|
|
||||||
Ok(DocumentContent::Raster(document))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supports(&self, path: &Path) -> bool {
|
|
||||||
use cosmic::iced_renderer::graphics::image::image_rs::ImageFormat;
|
|
||||||
|
|
||||||
ImageFormat::from_path(path).is_ok()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_supports() {
|
|
||||||
let loader = RasterLoader;
|
|
||||||
|
|
||||||
assert!(loader.supports(Path::new("test.png")));
|
|
||||||
assert!(loader.supports(Path::new("test.jpg")));
|
|
||||||
assert!(loader.supports(Path::new("test.jpeg")));
|
|
||||||
assert!(loader.supports(Path::new("test.webp")));
|
|
||||||
assert!(!loader.supports(Path::new("test.pdf")));
|
|
||||||
assert!(!loader.supports(Path::new("test.svg")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/infrastructure/loaders/svg_loader.rs
|
|
||||||
//
|
|
||||||
// Loader for SVG vector documents.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use crate::domain::document::core::content::DocumentContent;
|
|
||||||
use crate::domain::document::core::document::DocResult;
|
|
||||||
use crate::domain::document::types::vector::VectorDocument;
|
|
||||||
use crate::infrastructure::loaders::document_loader::DocumentLoader;
|
|
||||||
|
|
||||||
/// Loader for SVG vector documents.
|
|
||||||
pub struct SvgLoader;
|
|
||||||
|
|
||||||
impl DocumentLoader for SvgLoader {
|
|
||||||
fn load(&self, path: &Path) -> DocResult<DocumentContent> {
|
|
||||||
let document = VectorDocument::open(path)
|
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to load SVG document: {e}"))?;
|
|
||||||
|
|
||||||
Ok(DocumentContent::Vector(document))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supports(&self, path: &Path) -> bool {
|
|
||||||
if let Some(ext) = path.extension() {
|
|
||||||
let ext_str = ext.to_string_lossy().to_lowercase();
|
|
||||||
ext_str == "svg" || ext_str == "svgz"
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_supports() {
|
|
||||||
let loader = SvgLoader;
|
|
||||||
|
|
||||||
assert!(loader.supports(Path::new("test.svg")));
|
|
||||||
assert!(loader.supports(Path::new("test.SVG")));
|
|
||||||
assert!(loader.supports(Path::new("test.svgz")));
|
|
||||||
assert!(!loader.supports(Path::new("test.png")));
|
|
||||||
assert!(!loader.supports(Path::new("test.pdf")));
|
|
||||||
assert!(!loader.supports(Path::new("test.jpg")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/infrastructure/mod.rs
|
|
||||||
//
|
|
||||||
// Infrastructure layer: external dependencies, loaders, cache, and filesystem.
|
|
||||||
|
|
||||||
pub mod cache;
|
|
||||||
pub mod filesystem;
|
|
||||||
pub mod loaders;
|
|
||||||
pub mod system;
|
|
||||||
|
|
||||||
// Re-export loader factory
|
|
||||||
#[allow(unused_imports)]
|
|
||||||
pub use loaders::DocumentLoaderFactory;
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/infrastructure/system/mod.rs
|
|
||||||
//
|
|
||||||
// System integration: wallpaper, desktop environment utilities.
|
|
||||||
|
|
||||||
pub mod wallpaper;
|
|
||||||
|
|
||||||
// Re-export wallpaper function
|
|
||||||
pub use wallpaper::set_as_wallpaper;
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/infrastructure/system/wallpaper.rs
|
|
||||||
//
|
|
||||||
// Set desktop wallpaper across different desktop environments.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
/// Set an image as desktop wallpaper using multiple fallback methods.
|
|
||||||
///
|
|
||||||
/// Attempts the following methods in order:
|
|
||||||
/// 1. COSMIC Desktop (direct config file modification)
|
|
||||||
/// 2. wallpaper crate (KDE, XFCE, Windows, macOS)
|
|
||||||
/// 3. gsettings (GNOME)
|
|
||||||
/// 4. feh (tiling window managers)
|
|
||||||
pub fn set_as_wallpaper(path: &Path) {
|
|
||||||
// Canonicalize to absolute path.
|
|
||||||
let abs_path = match path.canonicalize() {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to canonicalize path {}: {}", path.display(), e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(path_str) = abs_path.to_str() else {
|
|
||||||
log::error!("Invalid UTF-8 in path: {}", abs_path.display());
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
log::info!("Attempting to set wallpaper: {path_str}");
|
|
||||||
|
|
||||||
// Method 1: Try COSMIC Desktop (direct config file modification).
|
|
||||||
if try_cosmic_wallpaper(path_str) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method 2: Try wallpaper crate (supports KDE, XFCE, Windows, macOS).
|
|
||||||
if try_wallpaper_crate(path_str) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method 3: Try GNOME via gsettings.
|
|
||||||
if try_gsettings_wallpaper(path_str) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method 4: Try feh (common on tiling WMs like i3, sway).
|
|
||||||
if try_feh_wallpaper(path_str) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log::error!("All methods failed to set wallpaper");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try setting wallpaper via COSMIC config file.
|
|
||||||
fn try_cosmic_wallpaper(path_str: &str) -> bool {
|
|
||||||
let Some(home) = dirs::home_dir() else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
let cosmic_config = home.join(".config/cosmic/com.system76.CosmicBackground/v1/all");
|
|
||||||
if !cosmic_config.exists() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let config_content = format!(
|
|
||||||
r#"(
|
|
||||||
output: "all",
|
|
||||||
source: Path("{path_str}"),
|
|
||||||
filter_by_theme: true,
|
|
||||||
rotation_frequency: 300,
|
|
||||||
filter_method: Lanczos,
|
|
||||||
scaling_mode: Zoom,
|
|
||||||
sampling_method: Alphanumeric,
|
|
||||||
)"#
|
|
||||||
);
|
|
||||||
|
|
||||||
match std::fs::write(&cosmic_config, config_content) {
|
|
||||||
Ok(()) => {
|
|
||||||
log::info!("Wallpaper set via COSMIC config");
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("Failed to write COSMIC config: {e}");
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try setting wallpaper via wallpaper crate.
|
|
||||||
fn try_wallpaper_crate(path_str: &str) -> bool {
|
|
||||||
match wallpaper::set_from_path(path_str) {
|
|
||||||
Ok(()) => {
|
|
||||||
log::info!("Wallpaper set via wallpaper crate");
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("wallpaper crate failed: {e}");
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try setting wallpaper via GNOME gsettings.
|
|
||||||
fn try_gsettings_wallpaper(path_str: &str) -> bool {
|
|
||||||
let uri = format!("file://{path_str}");
|
|
||||||
|
|
||||||
let output = match std::process::Command::new("gsettings")
|
|
||||||
.args(["set", "org.gnome.desktop.background", "picture-uri", &uri])
|
|
||||||
.output()
|
|
||||||
{
|
|
||||||
Ok(o) => o,
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("gsettings command failed: {e}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
log::warn!(
|
|
||||||
"gsettings failed: {}",
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("Wallpaper set via gsettings");
|
|
||||||
|
|
||||||
// Also set dark mode wallpaper.
|
|
||||||
let _ = std::process::Command::new("gsettings")
|
|
||||||
.args([
|
|
||||||
"set",
|
|
||||||
"org.gnome.desktop.background",
|
|
||||||
"picture-uri-dark",
|
|
||||||
&uri,
|
|
||||||
])
|
|
||||||
.output();
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try setting wallpaper via feh.
|
|
||||||
fn try_feh_wallpaper(path_str: &str) -> bool {
|
|
||||||
let Ok(output) = std::process::Command::new("feh")
|
|
||||||
.args(["--bg-scale", path_str])
|
|
||||||
.output()
|
|
||||||
else {
|
|
||||||
log::warn!("feh not available");
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
if output.status.success() {
|
|
||||||
log::info!("Wallpaper set via feh");
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
log::warn!("feh failed");
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
747
src/main.rs
747
src/main.rs
|
|
@ -1,43 +1,734 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
// src/main.rs
|
// src/main.rs
|
||||||
//
|
//
|
||||||
// Application entry point.
|
// Minimal COSMIC viewer entry point for images and PDFs.
|
||||||
|
|
||||||
mod ui;
|
|
||||||
mod application;
|
|
||||||
mod domain;
|
|
||||||
mod infrastructure;
|
|
||||||
|
|
||||||
mod config;
|
|
||||||
mod i18n;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use cosmic::app::Settings;
|
use cosmic::app::{Core, Settings, Task};
|
||||||
use crate::ui::NoctuaApp;
|
use cosmic::iced::{
|
||||||
|
event,
|
||||||
|
keyboard::{self, key::Named, Key},
|
||||||
|
mouse, Alignment, ContentFit, Event, Length, Limits, Subscription,
|
||||||
|
};
|
||||||
|
use cosmic::{executor, theme, widget, Application, Element};
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(version, about)]
|
#[command(
|
||||||
pub struct Args {
|
name = "noctua",
|
||||||
/// File to open on startup
|
version,
|
||||||
|
about = "A document and image viewer for COSMIC"
|
||||||
|
)]
|
||||||
|
struct Args {
|
||||||
|
/// Files to open.
|
||||||
#[arg(value_name = "FILE")]
|
#[arg(value_name = "FILE")]
|
||||||
pub file: Option<std::path::PathBuf>,
|
files: Vec<PathBuf>,
|
||||||
|
|
||||||
/// UI language (e.g. "en", "de")
|
/// Compatibility with the previous CLI harness.
|
||||||
#[arg(short, long, default_value = "en")]
|
#[arg(long = "open-document", value_name = "PATH")]
|
||||||
pub language: String,
|
document: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Open all supported documents in a directory.
|
||||||
|
#[arg(long = "open-directory", value_name = "PATH")]
|
||||||
|
directory: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
// Get the system's preferred languages.
|
enum DocumentKind {
|
||||||
let requested_languages = i18n_embed::DesktopLanguageRequester::requested_languages();
|
Raster,
|
||||||
|
Svg,
|
||||||
|
Pdf,
|
||||||
|
}
|
||||||
|
|
||||||
// Enable localizations to be applied.
|
#[derive(Clone, Debug)]
|
||||||
i18n::init(&requested_languages);
|
struct Document {
|
||||||
|
path: PathBuf,
|
||||||
|
kind: DocumentKind,
|
||||||
|
page: usize,
|
||||||
|
page_count: Option<usize>,
|
||||||
|
rendered_page: Option<PathBuf>,
|
||||||
|
rendering_page: Option<usize>,
|
||||||
|
prefetching_pages: Vec<usize>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Document {
|
||||||
|
fn new(path: PathBuf, kind: DocumentKind) -> Self {
|
||||||
|
let page_count = (kind == DocumentKind::Pdf)
|
||||||
|
.then(|| pdf_page_count(&path))
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
path,
|
||||||
|
kind,
|
||||||
|
page: 0,
|
||||||
|
page_count,
|
||||||
|
rendered_page: None,
|
||||||
|
rendering_page: None,
|
||||||
|
prefetching_pages: Vec::new(),
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> String {
|
||||||
|
self.path
|
||||||
|
.file_name()
|
||||||
|
.and_then(OsStr::to_str)
|
||||||
|
.unwrap_or("Noctua")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct Flags {
|
||||||
|
documents: Vec<Document>,
|
||||||
|
initial_index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum Message {
|
||||||
|
PreviousDocument,
|
||||||
|
NextDocument,
|
||||||
|
PreviousPage,
|
||||||
|
NextPage,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
|
ResetZoom,
|
||||||
|
ModifiersChanged(keyboard::Modifiers),
|
||||||
|
Scroll(f32),
|
||||||
|
PdfRendered {
|
||||||
|
path: PathBuf,
|
||||||
|
page: usize,
|
||||||
|
result: Result<PathBuf, String>,
|
||||||
|
},
|
||||||
|
PdfPrefetched {
|
||||||
|
path: PathBuf,
|
||||||
|
page: usize,
|
||||||
|
result: Result<PathBuf, String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NoctuaApp {
|
||||||
|
core: Core,
|
||||||
|
documents: Vec<Document>,
|
||||||
|
current: usize,
|
||||||
|
zoom: f32,
|
||||||
|
modifiers: keyboard::Modifiers,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> cosmic::iced::Result {
|
||||||
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
|
||||||
|
|
||||||
env_logger::init();
|
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
let flags = Flags::from_args(args);
|
||||||
|
|
||||||
cosmic::app::run::<NoctuaApp>(Settings::default(), ui::app::Flags::Args(args))
|
let settings = Settings::default().size_limits(Limits::NONE.min_width(480.0).min_height(320.0));
|
||||||
.map_err(|e| anyhow::anyhow!(e))
|
|
||||||
|
cosmic::app::run::<NoctuaApp>(settings, flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Flags {
|
||||||
|
fn from_args(args: Args) -> Self {
|
||||||
|
let mut requested = args.files;
|
||||||
|
|
||||||
|
if let Some(path) = args.document {
|
||||||
|
requested.push(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(path) = args.directory {
|
||||||
|
requested.push(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (documents, initial_index) = collect_documents(&requested);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
documents,
|
||||||
|
initial_index,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Application for NoctuaApp {
|
||||||
|
type Executor = executor::Default;
|
||||||
|
type Flags = Flags;
|
||||||
|
type Message = Message;
|
||||||
|
|
||||||
|
const APP_ID: &'static str = "org.codeberg.wfx.Noctua";
|
||||||
|
|
||||||
|
fn core(&self) -> &Core {
|
||||||
|
&self.core
|
||||||
|
}
|
||||||
|
|
||||||
|
fn core_mut(&mut self) -> &mut Core {
|
||||||
|
&mut self.core
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(mut core: Core, flags: Self::Flags) -> (Self, Task<Self::Message>) {
|
||||||
|
core.window.content_container = false;
|
||||||
|
|
||||||
|
let current = flags
|
||||||
|
.initial_index
|
||||||
|
.min(flags.documents.len().saturating_sub(1));
|
||||||
|
|
||||||
|
let mut app = Self {
|
||||||
|
core,
|
||||||
|
documents: flags.documents,
|
||||||
|
current,
|
||||||
|
zoom: 1.0,
|
||||||
|
modifiers: keyboard::Modifiers::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
app.refresh_title();
|
||||||
|
let task = app.ensure_current_rendered();
|
||||||
|
|
||||||
|
(app, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, message: Self::Message) -> Task<Self::Message> {
|
||||||
|
match message {
|
||||||
|
Message::PreviousDocument => {
|
||||||
|
if self.current > 0 {
|
||||||
|
self.current -= 1;
|
||||||
|
self.zoom = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::NextDocument => {
|
||||||
|
if self.current + 1 < self.documents.len() {
|
||||||
|
self.current += 1;
|
||||||
|
self.zoom = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::PreviousPage => {
|
||||||
|
self.previous_pdf_page();
|
||||||
|
}
|
||||||
|
Message::NextPage => {
|
||||||
|
self.next_pdf_page();
|
||||||
|
}
|
||||||
|
Message::ZoomIn => {
|
||||||
|
self.zoom_in();
|
||||||
|
}
|
||||||
|
Message::ZoomOut => {
|
||||||
|
self.zoom_out();
|
||||||
|
}
|
||||||
|
Message::ResetZoom => {
|
||||||
|
self.zoom = 1.0;
|
||||||
|
}
|
||||||
|
Message::ModifiersChanged(modifiers) => {
|
||||||
|
self.modifiers = modifiers;
|
||||||
|
}
|
||||||
|
Message::Scroll(delta) => {
|
||||||
|
if self.modifiers.control() {
|
||||||
|
if delta > 0.0 {
|
||||||
|
self.zoom_in();
|
||||||
|
} else if delta < 0.0 {
|
||||||
|
self.zoom_out();
|
||||||
|
}
|
||||||
|
} else if delta > 0.0 {
|
||||||
|
self.previous_pdf_page();
|
||||||
|
} else if delta < 0.0 {
|
||||||
|
self.next_pdf_page();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::PdfRendered { path, page, result } => {
|
||||||
|
if let Some(document) = self
|
||||||
|
.documents
|
||||||
|
.iter_mut()
|
||||||
|
.find(|document| document.path == path)
|
||||||
|
{
|
||||||
|
if document.rendering_page == Some(page) {
|
||||||
|
document.rendering_page = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if document.page == page {
|
||||||
|
match result {
|
||||||
|
Ok(rendered) => {
|
||||||
|
document.rendered_page = Some(rendered);
|
||||||
|
document.error = None;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
document.rendered_page = None;
|
||||||
|
document.error = Some(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::PdfPrefetched { path, page, result } => {
|
||||||
|
if let Some(document) = self
|
||||||
|
.documents
|
||||||
|
.iter_mut()
|
||||||
|
.find(|document| document.path == path)
|
||||||
|
{
|
||||||
|
document
|
||||||
|
.prefetching_pages
|
||||||
|
.retain(|pending| *pending != page);
|
||||||
|
|
||||||
|
if document.page == page && document.rendered_page.is_none() {
|
||||||
|
if let Ok(rendered) = result {
|
||||||
|
document.rendered_page = Some(rendered);
|
||||||
|
document.error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.refresh_title();
|
||||||
|
|
||||||
|
self.ensure_current_rendered()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscription(&self) -> Subscription<Self::Message> {
|
||||||
|
event::listen_raw(|event, status, _| match event {
|
||||||
|
Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => {
|
||||||
|
Some(Message::ModifiersChanged(modifiers))
|
||||||
|
}
|
||||||
|
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||||
|
key: Key::Named(Named::PageUp | Named::ArrowUp | Named::ArrowLeft),
|
||||||
|
..
|
||||||
|
}) if status == event::Status::Ignored => Some(Message::PreviousPage),
|
||||||
|
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||||
|
key: Key::Named(Named::PageDown | Named::ArrowDown | Named::ArrowRight),
|
||||||
|
..
|
||||||
|
}) if status == event::Status::Ignored => Some(Message::NextPage),
|
||||||
|
Event::Mouse(mouse::Event::WheelScrolled { delta })
|
||||||
|
if status == event::Status::Ignored =>
|
||||||
|
{
|
||||||
|
scroll_delta_y(delta).map(Message::Scroll)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self) -> Element<'_, Self::Message> {
|
||||||
|
let content = match self.current_document() {
|
||||||
|
Some(document) => self.document_view(document),
|
||||||
|
None => empty_view("No document selected"),
|
||||||
|
};
|
||||||
|
|
||||||
|
widget::container(content)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.center_x(Length::Fill)
|
||||||
|
.center_y(Length::Fill)
|
||||||
|
.class(theme::Container::WindowBackground)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn footer(&self) -> Option<Element<'_, Self::Message>> {
|
||||||
|
let Some(document) = self.current_document() else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
let has_previous_document = self.current > 0;
|
||||||
|
let has_next_document = self.current + 1 < self.documents.len();
|
||||||
|
let has_previous_page = document.kind == DocumentKind::Pdf && document.page > 0;
|
||||||
|
let has_next_page = document.kind == DocumentKind::Pdf
|
||||||
|
&& document
|
||||||
|
.page_count
|
||||||
|
.is_none_or(|page_count| document.page + 1 < page_count);
|
||||||
|
|
||||||
|
let page_label = if document.kind == DocumentKind::Pdf {
|
||||||
|
match document.page_count {
|
||||||
|
Some(page_count) => format!("Page {} / {}", document.page + 1, page_count),
|
||||||
|
None => format!("Page {}", document.page + 1),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
format!("{} / {}", self.current + 1, self.documents.len())
|
||||||
|
};
|
||||||
|
|
||||||
|
let row = widget::row::with_capacity(9)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.spacing(8)
|
||||||
|
.padding(8)
|
||||||
|
.push(
|
||||||
|
widget::button::standard("Previous")
|
||||||
|
.on_press_maybe(has_previous_document.then_some(Message::PreviousDocument)),
|
||||||
|
)
|
||||||
|
.push(
|
||||||
|
widget::button::standard("Next")
|
||||||
|
.on_press_maybe(has_next_document.then_some(Message::NextDocument)),
|
||||||
|
)
|
||||||
|
.push(widget::space::horizontal().width(Length::Fill))
|
||||||
|
.push(
|
||||||
|
widget::button::standard("-")
|
||||||
|
.on_press(Message::ZoomOut)
|
||||||
|
.width(Length::Fixed(36.0)),
|
||||||
|
)
|
||||||
|
.push(widget::button::standard("100%").on_press(Message::ResetZoom))
|
||||||
|
.push(
|
||||||
|
widget::button::standard("+")
|
||||||
|
.on_press(Message::ZoomIn)
|
||||||
|
.width(Length::Fixed(36.0)),
|
||||||
|
)
|
||||||
|
.push(widget::space::horizontal().width(Length::Fill))
|
||||||
|
.push(widget::text::body(page_label))
|
||||||
|
.push(
|
||||||
|
widget::button::standard("Prev page")
|
||||||
|
.on_press_maybe(has_previous_page.then_some(Message::PreviousPage)),
|
||||||
|
)
|
||||||
|
.push(
|
||||||
|
widget::button::standard("Next page")
|
||||||
|
.on_press_maybe(has_next_page.then_some(Message::NextPage)),
|
||||||
|
);
|
||||||
|
|
||||||
|
Some(row.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NoctuaApp {
|
||||||
|
fn zoom_in(&mut self) {
|
||||||
|
self.zoom = (self.zoom * 1.2).min(5.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zoom_out(&mut self) {
|
||||||
|
self.zoom = (self.zoom / 1.2).max(0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn previous_pdf_page(&mut self) {
|
||||||
|
if let Some(document) = self.current_document_mut() {
|
||||||
|
if document.kind == DocumentKind::Pdf && document.page > 0 {
|
||||||
|
document.page -= 1;
|
||||||
|
document.rendered_page = None;
|
||||||
|
document.error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_pdf_page(&mut self) {
|
||||||
|
if let Some(document) = self.current_document_mut() {
|
||||||
|
if document.kind == DocumentKind::Pdf {
|
||||||
|
let page_count = document.page_count.unwrap_or(document.page + 2);
|
||||||
|
if document.page + 1 < page_count {
|
||||||
|
document.page += 1;
|
||||||
|
document.rendered_page = None;
|
||||||
|
document.error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_document(&self) -> Option<&Document> {
|
||||||
|
self.documents.get(self.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_document_mut(&mut self) -> Option<&mut Document> {
|
||||||
|
self.documents.get_mut(self.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_title(&mut self) {
|
||||||
|
let title = self
|
||||||
|
.current_document()
|
||||||
|
.map_or_else(|| "Noctua".to_string(), Document::name);
|
||||||
|
|
||||||
|
self.core.set_header_title(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_current_rendered(&mut self) -> Task<Message> {
|
||||||
|
let render_task = {
|
||||||
|
let Some(document) = self.current_document_mut() else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
|
||||||
|
if document.kind != DocumentKind::Pdf {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
|
||||||
|
if document.rendered_page.is_some() {
|
||||||
|
return self.ensure_adjacent_rendered();
|
||||||
|
}
|
||||||
|
|
||||||
|
if document.rendering_page == Some(document.page) {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(cached) = cached_pdf_page(&document.path, document.page) {
|
||||||
|
document.rendered_page = Some(cached);
|
||||||
|
document.error = None;
|
||||||
|
document.rendering_page = None;
|
||||||
|
return self.ensure_adjacent_rendered();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.rendering_page = Some(document.page);
|
||||||
|
document.error = None;
|
||||||
|
|
||||||
|
render_pdf_task(
|
||||||
|
document.path.clone(),
|
||||||
|
document.page,
|
||||||
|
|path, page, result| Message::PdfRendered { path, page, result },
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
Task::batch([render_task, self.ensure_adjacent_rendered()])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_adjacent_rendered(&mut self) -> Task<Message> {
|
||||||
|
let Some(document) = self.current_document_mut() else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
|
||||||
|
if document.kind != DocumentKind::Pdf || document.rendered_page.is_none() {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pages = Vec::with_capacity(2);
|
||||||
|
if document
|
||||||
|
.page_count
|
||||||
|
.is_none_or(|page_count| document.page + 1 < page_count)
|
||||||
|
{
|
||||||
|
pages.push(document.page + 1);
|
||||||
|
}
|
||||||
|
if document.page > 0 {
|
||||||
|
pages.push(document.page - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = document.path.clone();
|
||||||
|
let pages = pages
|
||||||
|
.into_iter()
|
||||||
|
.filter(|page| document.rendering_page != Some(*page))
|
||||||
|
.filter(|page| !document.prefetching_pages.contains(page))
|
||||||
|
.filter(|page| cached_pdf_page(&path, *page).is_none())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
document.prefetching_pages.extend(pages.iter().copied());
|
||||||
|
|
||||||
|
let tasks = pages
|
||||||
|
.into_iter()
|
||||||
|
.map(|page| {
|
||||||
|
render_pdf_task(path.clone(), page, |path, page, result| {
|
||||||
|
Message::PdfPrefetched { path, page, result }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Task::batch(tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn document_view(&self, document: &Document) -> Element<'_, Message> {
|
||||||
|
if let Some(error) = &document.error {
|
||||||
|
return empty_view(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
match document.kind {
|
||||||
|
DocumentKind::Raster => widget::image(widget::image::Handle::from_path(&document.path))
|
||||||
|
.content_fit(ContentFit::Contain)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.scale(self.zoom)
|
||||||
|
.into(),
|
||||||
|
DocumentKind::Svg => widget::svg(widget::svg::Handle::from_path(&document.path))
|
||||||
|
.content_fit(ContentFit::Contain)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.into(),
|
||||||
|
DocumentKind::Pdf => match &document.rendered_page {
|
||||||
|
Some(path) => widget::image(widget::image::Handle::from_path(path))
|
||||||
|
.content_fit(ContentFit::Contain)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.scale(self.zoom)
|
||||||
|
.into(),
|
||||||
|
None => empty_view("Rendering PDF page..."),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_view<'a>(message: impl Into<String>) -> Element<'a, Message> {
|
||||||
|
widget::column::with_capacity(3)
|
||||||
|
.align_x(Alignment::Center)
|
||||||
|
.spacing(8)
|
||||||
|
.push(widget::space::vertical())
|
||||||
|
.push(widget::icon::from_name("image-x-generic-symbolic").size(64))
|
||||||
|
.push(widget::text::body(message.into()))
|
||||||
|
.push(widget::space::vertical())
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scroll_delta_y(delta: mouse::ScrollDelta) -> Option<f32> {
|
||||||
|
let y = match delta {
|
||||||
|
mouse::ScrollDelta::Lines { y, .. } => y,
|
||||||
|
mouse::ScrollDelta::Pixels { y, .. } => y / 60.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
(y.abs() > f32::EPSILON).then_some(y)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_documents(requested: &[PathBuf]) -> (Vec<Document>, usize) {
|
||||||
|
if requested.is_empty() {
|
||||||
|
return (Vec::new(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if requested.len() == 1 {
|
||||||
|
let path = &requested[0];
|
||||||
|
|
||||||
|
if path.is_dir() {
|
||||||
|
return (scan_directory(path), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let documents = scan_directory(parent);
|
||||||
|
if let Some(index) = documents
|
||||||
|
.iter()
|
||||||
|
.position(|document| same_path(&document.path, path))
|
||||||
|
{
|
||||||
|
return (documents, index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let documents = requested
|
||||||
|
.iter()
|
||||||
|
.flat_map(|path| {
|
||||||
|
if path.is_dir() {
|
||||||
|
scan_directory(path)
|
||||||
|
} else {
|
||||||
|
detect_kind(path)
|
||||||
|
.map(|kind| vec![Document::new(path.clone(), kind)])
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
(documents, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan_directory(dir: &Path) -> Vec<Document> {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut paths = entries
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.filter(|path| path.is_file())
|
||||||
|
.filter_map(|path| detect_kind(&path).map(|kind| (path, kind)))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
paths.sort_by(|(left, _), (right, _)| left.cmp(right));
|
||||||
|
|
||||||
|
paths
|
||||||
|
.into_iter()
|
||||||
|
.map(|(path, kind)| Document::new(path, kind))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_kind(path: &Path) -> Option<DocumentKind> {
|
||||||
|
match path
|
||||||
|
.extension()
|
||||||
|
.and_then(OsStr::to_str)
|
||||||
|
.map(str::to_ascii_lowercase)
|
||||||
|
.as_deref()
|
||||||
|
{
|
||||||
|
Some("pdf") => Some(DocumentKind::Pdf),
|
||||||
|
Some("svg") => Some(DocumentKind::Svg),
|
||||||
|
Some("png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "tiff" | "tif") => {
|
||||||
|
Some(DocumentKind::Raster)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn same_path(left: &Path, right: &Path) -> bool {
|
||||||
|
left == right || left.canonicalize().ok().as_deref() == right.canonicalize().ok().as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pdf_page_count(path: &Path) -> Option<usize> {
|
||||||
|
let output = Command::new("pdfinfo").arg(path).output().ok()?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
stdout.lines().find_map(|line| {
|
||||||
|
line.strip_prefix("Pages:")
|
||||||
|
.and_then(|value| value.trim().parse::<usize>().ok())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_pdf_task(
|
||||||
|
path: PathBuf,
|
||||||
|
page: usize,
|
||||||
|
on_done: impl FnOnce(PathBuf, usize, Result<PathBuf, String>) -> Message + Send + 'static,
|
||||||
|
) -> Task<Message> {
|
||||||
|
Task::perform(
|
||||||
|
async move {
|
||||||
|
let result = render_pdf_page(&path, page);
|
||||||
|
(path, page, result)
|
||||||
|
},
|
||||||
|
move |(path, page, result)| cosmic::Action::App(on_done(path, page, result)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cached_pdf_page(path: &Path, page: usize) -> Option<PathBuf> {
|
||||||
|
let output = pdf_page_output(path, page);
|
||||||
|
output.exists().then_some(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pdf_page_output(path: &Path, page: usize) -> PathBuf {
|
||||||
|
std::env::temp_dir()
|
||||||
|
.join(format!(
|
||||||
|
"noctua-pdf-{}-{}",
|
||||||
|
path_hash(path),
|
||||||
|
page.saturating_add(1)
|
||||||
|
))
|
||||||
|
.with_extension("png")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_pdf_page(path: &Path, page: usize) -> Result<PathBuf, String> {
|
||||||
|
if let Some(cached) = cached_pdf_page(path, page) {
|
||||||
|
return Ok(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = pdf_page_output(path, page);
|
||||||
|
let prefix = output.with_extension("");
|
||||||
|
|
||||||
|
let status = Command::new("pdftocairo")
|
||||||
|
.arg("-png")
|
||||||
|
.arg("-singlefile")
|
||||||
|
.arg("-f")
|
||||||
|
.arg(page.saturating_add(1).to_string())
|
||||||
|
.arg("-l")
|
||||||
|
.arg(page.saturating_add(1).to_string())
|
||||||
|
.arg("-scale-to")
|
||||||
|
.arg("2400")
|
||||||
|
.arg(path)
|
||||||
|
.arg(&prefix)
|
||||||
|
.status()
|
||||||
|
.map_err(|err| format!("Failed to run pdftocairo: {err}"))?;
|
||||||
|
|
||||||
|
if status.success() && output.exists() {
|
||||||
|
Ok(output)
|
||||||
|
} else {
|
||||||
|
Err(format!(
|
||||||
|
"Failed to render page {} of {}",
|
||||||
|
page.saturating_add(1),
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_hash(path: &Path) -> u64 {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
|
||||||
|
match path.canonicalize() {
|
||||||
|
Ok(path) => path.hash(&mut hasher),
|
||||||
|
Err(_) => path.hash(&mut hasher),
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(metadata) = path.metadata() {
|
||||||
|
metadata.len().hash(&mut hasher);
|
||||||
|
if let Ok(modified) = metadata.modified() {
|
||||||
|
modified.hash(&mut hasher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasher.finish()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
338
src/patch.rs
Normal file
338
src/patch.rs
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// src/patch.rs
|
||||||
|
//
|
||||||
|
// Patch file executor for scripted workspace operations and testing.
|
||||||
|
|
||||||
|
use crate::active_state::ActiveState;
|
||||||
|
use crate::workspace::model::{Item, ItemKind, Session, SessionType, Workspace};
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// A single step in a patch file. Each variant maps directly to a JSON key.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub enum PatchStep {
|
||||||
|
NewWorkspace(String),
|
||||||
|
LoadWorkspace(String),
|
||||||
|
SaveWorkspace(String),
|
||||||
|
|
||||||
|
CreateSession {
|
||||||
|
name: String,
|
||||||
|
session_type: String,
|
||||||
|
},
|
||||||
|
DeleteSession(String),
|
||||||
|
CopySession(String),
|
||||||
|
RenameSession {
|
||||||
|
session: String,
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
AddItem {
|
||||||
|
session: String,
|
||||||
|
name: String,
|
||||||
|
kind: String,
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
RemoveItem {
|
||||||
|
session: String,
|
||||||
|
item: String,
|
||||||
|
},
|
||||||
|
RenameItem {
|
||||||
|
session: String,
|
||||||
|
item: String,
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
CopyItem {
|
||||||
|
from: String,
|
||||||
|
item: String,
|
||||||
|
to: String,
|
||||||
|
},
|
||||||
|
MoveItem {
|
||||||
|
from: String,
|
||||||
|
item: String,
|
||||||
|
to: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
SelectSession(String),
|
||||||
|
SelectItem(usize),
|
||||||
|
NextItem,
|
||||||
|
PreviousItem,
|
||||||
|
MoveItemUp,
|
||||||
|
MoveItemDown,
|
||||||
|
MoveItemToIndex(usize),
|
||||||
|
|
||||||
|
ViewWorkspace,
|
||||||
|
ViewSession(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(path: &Path) -> Result<Vec<PatchStep>> {
|
||||||
|
let content = std::fs::read_to_string(path).context("Failed to read patch file")?;
|
||||||
|
serde_json::from_str(&content).context("Failed to parse patch file")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(path: &Path) {
|
||||||
|
let steps = match load(path) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("patch: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let total = steps.len();
|
||||||
|
let width = total.to_string().len();
|
||||||
|
|
||||||
|
println!("patch: '{}' ({total} steps)\n", path.display());
|
||||||
|
|
||||||
|
let mut ws = Workspace::new("untitled");
|
||||||
|
let mut active = ActiveState::new();
|
||||||
|
|
||||||
|
for (i, step) in steps.into_iter().enumerate() {
|
||||||
|
let n = i + 1;
|
||||||
|
print!("[{n:>width$}/{total}] ");
|
||||||
|
if let Err(e) = execute(step, &mut ws, &mut active) {
|
||||||
|
println!("[ERR] {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\npatch: done.");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute(step: PatchStep, ws: &mut Workspace, active: &mut ActiveState) -> Result<()> {
|
||||||
|
match step {
|
||||||
|
PatchStep::NewWorkspace(name) => {
|
||||||
|
println!("New Workspace {name:?}");
|
||||||
|
*ws = Workspace::new(&name);
|
||||||
|
active.reset();
|
||||||
|
}
|
||||||
|
PatchStep::LoadWorkspace(path) => {
|
||||||
|
*ws = Workspace::load(&path)?;
|
||||||
|
active.reset();
|
||||||
|
println!("Load Workspace {path:?} ({} sessions)", ws.sessions.len());
|
||||||
|
}
|
||||||
|
PatchStep::SaveWorkspace(path) => {
|
||||||
|
ws.save(&path)?;
|
||||||
|
println!("Save Workspace {path:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
PatchStep::CreateSession { name, session_type } => {
|
||||||
|
let st = parse_session_type(&session_type)?;
|
||||||
|
let session = Session::new(&name, st);
|
||||||
|
let id = session.id;
|
||||||
|
ws.add_session(session);
|
||||||
|
active.select_session_by_id(ws, id)?;
|
||||||
|
println!("Create Session {name:?}");
|
||||||
|
}
|
||||||
|
PatchStep::DeleteSession(name) => {
|
||||||
|
let id = ws.session_by_name(&name)?.id;
|
||||||
|
ws.remove_session(id)?;
|
||||||
|
if active.active_session_id() == Some(id) {
|
||||||
|
active.clear_session();
|
||||||
|
}
|
||||||
|
println!("Delete Session {name:?}");
|
||||||
|
}
|
||||||
|
PatchStep::CopySession(name) => {
|
||||||
|
let id = ws.session_by_name(&name)?.id;
|
||||||
|
let new_id = ws.copy_session(id)?;
|
||||||
|
let new_name = ws.session(new_id)?.name.clone();
|
||||||
|
active.select_session_by_id(ws, new_id)?;
|
||||||
|
println!("Copy Session {name:?} -> {new_name:?}");
|
||||||
|
}
|
||||||
|
PatchStep::RenameSession { session, name } => {
|
||||||
|
let id = ws.session_by_name(&session)?.id;
|
||||||
|
ws.rename_session(id, &name)?;
|
||||||
|
println!("Rename Session {session:?} -> {name:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
PatchStep::AddItem {
|
||||||
|
session,
|
||||||
|
name,
|
||||||
|
kind,
|
||||||
|
path,
|
||||||
|
} => {
|
||||||
|
let sid = ws.session_by_name(&session)?.id;
|
||||||
|
let item = Item::new(&name, parse_item_kind(&kind, &path)?);
|
||||||
|
ws.add_item(sid, item)?;
|
||||||
|
if active.active_session_id() == Some(sid) {
|
||||||
|
active.select_last_item(ws);
|
||||||
|
}
|
||||||
|
println!("Add Item {name:?} to {session:?}");
|
||||||
|
}
|
||||||
|
PatchStep::RemoveItem { session, item } => {
|
||||||
|
let sid = ws.session_by_name(&session)?.id;
|
||||||
|
let iid = ws.item_by_name(sid, &item)?.id;
|
||||||
|
ws.remove_item(sid, iid)?;
|
||||||
|
if active.active_session_id() == Some(sid) {
|
||||||
|
active.adjust_after_remove(ws);
|
||||||
|
}
|
||||||
|
println!("Remove Item {item:?} from {session:?}");
|
||||||
|
}
|
||||||
|
PatchStep::RenameItem {
|
||||||
|
session,
|
||||||
|
item,
|
||||||
|
name,
|
||||||
|
} => {
|
||||||
|
let sid = ws.session_by_name(&session)?.id;
|
||||||
|
let iid = ws.item_by_name(sid, &item)?.id;
|
||||||
|
ws.rename_item(sid, iid, &name)?;
|
||||||
|
println!("Rename Item {item:?} -> {name:?}");
|
||||||
|
}
|
||||||
|
PatchStep::CopyItem { from, item, to } => {
|
||||||
|
let from_id = ws.session_by_name(&from)?.id;
|
||||||
|
let iid = ws.item_by_name(from_id, &item)?.id;
|
||||||
|
let to_id = ws.session_by_name(&to)?.id;
|
||||||
|
ws.copy_item(from_id, iid, to_id, usize::MAX)?;
|
||||||
|
println!("Copy Item {item:?}: {from:?} -> {to:?}");
|
||||||
|
}
|
||||||
|
PatchStep::MoveItem { from, item, to } => {
|
||||||
|
let from_id = ws.session_by_name(&from)?.id;
|
||||||
|
let iid = ws.item_by_name(from_id, &item)?.id;
|
||||||
|
let to_id = ws.session_by_name(&to)?.id;
|
||||||
|
ws.move_item(from_id, iid, to_id, usize::MAX)?;
|
||||||
|
if active.active_session_id() == Some(from_id) {
|
||||||
|
active.adjust_after_remove(ws);
|
||||||
|
}
|
||||||
|
println!("Move Item {item:?}: {from:?} -> {to:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
PatchStep::SelectSession(name) => {
|
||||||
|
active.select_session(ws, &name)?;
|
||||||
|
println!("Select Session {name:?}");
|
||||||
|
print_context(ws, active);
|
||||||
|
}
|
||||||
|
PatchStep::SelectItem(index) => {
|
||||||
|
active.select_item(ws, index)?;
|
||||||
|
println!("Select Item [{index}]");
|
||||||
|
print_context(ws, active);
|
||||||
|
}
|
||||||
|
PatchStep::NextItem => {
|
||||||
|
active.next_item(ws);
|
||||||
|
println!("Next Item");
|
||||||
|
print_context(ws, active);
|
||||||
|
}
|
||||||
|
PatchStep::PreviousItem => {
|
||||||
|
active.previous_item();
|
||||||
|
println!("Previous Item");
|
||||||
|
print_context(ws, active);
|
||||||
|
}
|
||||||
|
PatchStep::MoveItemUp => {
|
||||||
|
let sid = active.active_session_id().context("No active session")?;
|
||||||
|
let idx = active.active_item_index().context("No active item")?;
|
||||||
|
if idx == 0 {
|
||||||
|
bail!("Already at top")
|
||||||
|
}
|
||||||
|
let iid = active.active_item_id(ws).context("No active item")?;
|
||||||
|
ws.move_item(sid, iid, sid, idx - 1)?;
|
||||||
|
active.select_item(ws, idx - 1)?;
|
||||||
|
println!("Move Item Up");
|
||||||
|
print_context(ws, active);
|
||||||
|
}
|
||||||
|
PatchStep::MoveItemDown => {
|
||||||
|
let sid = active.active_session_id().context("No active session")?;
|
||||||
|
let idx = active.active_item_index().context("No active item")?;
|
||||||
|
let count = active.active_session(ws).map_or(0, |s| s.items.len());
|
||||||
|
if idx + 1 >= count {
|
||||||
|
bail!("Already at bottom")
|
||||||
|
}
|
||||||
|
let iid = active.active_item_id(ws).context("No active item")?;
|
||||||
|
ws.move_item(sid, iid, sid, idx + 1)?;
|
||||||
|
active.select_item(ws, idx + 1)?;
|
||||||
|
println!("Move Item Down");
|
||||||
|
print_context(ws, active);
|
||||||
|
}
|
||||||
|
PatchStep::MoveItemToIndex(target) => {
|
||||||
|
let sid = active.active_session_id().context("No active session")?;
|
||||||
|
let iid = active.active_item_id(ws).context("No active item")?;
|
||||||
|
ws.move_item(sid, iid, sid, target)?;
|
||||||
|
let count = active.active_session(ws).map_or(0, |s| s.items.len());
|
||||||
|
active.select_item(ws, target.min(count.saturating_sub(1)))?;
|
||||||
|
println!("Move Item To [{target}]");
|
||||||
|
print_context(ws, active);
|
||||||
|
}
|
||||||
|
|
||||||
|
PatchStep::ViewWorkspace => {
|
||||||
|
println!(
|
||||||
|
"View Workspace {:?} {} sessions",
|
||||||
|
ws.name,
|
||||||
|
ws.sessions.len()
|
||||||
|
);
|
||||||
|
for s in &ws.sessions {
|
||||||
|
let m = if active.active_session_id() == Some(s.id) {
|
||||||
|
"=>"
|
||||||
|
} else {
|
||||||
|
" "
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" {m} {:?} ({:?}) {} items",
|
||||||
|
s.name,
|
||||||
|
s.session_type,
|
||||||
|
s.items.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PatchStep::ViewSession(name) => {
|
||||||
|
let session = ws.session_by_name(&name)?;
|
||||||
|
println!(
|
||||||
|
"View Session {:?} ({:?}) {} items",
|
||||||
|
session.name,
|
||||||
|
session.session_type,
|
||||||
|
session.items.len()
|
||||||
|
);
|
||||||
|
for (i, item) in session.items.iter().enumerate() {
|
||||||
|
let m = if active.active_session_id() == Some(session.id)
|
||||||
|
&& active.active_item_index() == Some(i)
|
||||||
|
{
|
||||||
|
"=>"
|
||||||
|
} else {
|
||||||
|
" "
|
||||||
|
};
|
||||||
|
let kind = match &item.kind {
|
||||||
|
ItemKind::Portable { .. } => "portable",
|
||||||
|
ItemKind::Raster { .. } => "raster",
|
||||||
|
ItemKind::Vector { .. } => "vector",
|
||||||
|
ItemKind::Document { .. } => "document",
|
||||||
|
};
|
||||||
|
println!(" {m} [{i}] {:?} ({kind})", item.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_context(ws: &Workspace, active: &ActiveState) {
|
||||||
|
let Some(session) = active.active_session(ws) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let item_info = match active.active_item(ws) {
|
||||||
|
Some(item) => format!(
|
||||||
|
"[{}] {:?}",
|
||||||
|
active.active_item_index().unwrap_or(0),
|
||||||
|
item.name
|
||||||
|
),
|
||||||
|
None => "-".into(),
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" {:?} {} items active: {item_info}",
|
||||||
|
session.name,
|
||||||
|
session.items.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_session_type(s: &str) -> Result<SessionType> {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"browser" => Ok(SessionType::Browser),
|
||||||
|
"collection" => Ok(SessionType::Collection),
|
||||||
|
_ => bail!("Unknown session type: {s:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_item_kind(kind: &str, path: &str) -> Result<ItemKind> {
|
||||||
|
let path = PathBuf::from(path);
|
||||||
|
match kind.to_lowercase().as_str() {
|
||||||
|
"portable" | "pdf" => Ok(ItemKind::Portable { path }),
|
||||||
|
"raster" | "image" => Ok(ItemKind::Raster { path }),
|
||||||
|
"vector" | "svg" => Ok(ItemKind::Vector { path }),
|
||||||
|
"document" => Ok(ItemKind::Document { path }),
|
||||||
|
_ => bail!("Unknown item kind: {kind:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/render/converter.rs
Normal file
4
src/render/converter.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// src/render/converter.rs
|
||||||
|
//
|
||||||
|
// Converts raw document data into renderable page output.
|
||||||
7
src/render/mod.rs
Normal file
7
src/render/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// src/render/mod.rs
|
||||||
|
//
|
||||||
|
// Rendering subsystem: converts document content into displayable output.
|
||||||
|
|
||||||
|
pub mod converter;
|
||||||
|
pub mod thumbnail;
|
||||||
4
src/render/thumbnail.rs
Normal file
4
src/render/thumbnail.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// src/render/thumbnail.rs
|
||||||
|
//
|
||||||
|
// Thumbnail cache following the freedesktop.org thumbnail spec.
|
||||||
403
src/ui/app.rs
403
src/ui/app.rs
|
|
@ -1,403 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/ui/app/app.rs
|
|
||||||
//
|
|
||||||
// COSMIC application wiring and main app struct.
|
|
||||||
|
|
||||||
use super::message::AppMessage;
|
|
||||||
use super::model::{AppModel, ViewMode};
|
|
||||||
use super::update;
|
|
||||||
use crate::ui::views;
|
|
||||||
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use cosmic::app::{context_drawer, Core};
|
|
||||||
use cosmic::cosmic_config::{self, CosmicConfigEntry};
|
|
||||||
use cosmic::iced::keyboard::{self, key::Named, Key, Modifiers};
|
|
||||||
use cosmic::iced::time;
|
|
||||||
use cosmic::iced::window;
|
|
||||||
use cosmic::iced::Subscription;
|
|
||||||
use cosmic::widget::nav_bar;
|
|
||||||
use cosmic::{Action, Element, Task};
|
|
||||||
|
|
||||||
use crate::application::DocumentManager;
|
|
||||||
use crate::config::AppConfig;
|
|
||||||
use crate::Args;
|
|
||||||
|
|
||||||
/// Flags passed from `main` into the application.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum Flags {
|
|
||||||
Args(Args),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Context page displayed in right drawer.
|
|
||||||
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
|
|
||||||
pub enum ContextPage {
|
|
||||||
#[default]
|
|
||||||
Properties,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Main application type.
|
|
||||||
pub struct NoctuaApp {
|
|
||||||
core: Core,
|
|
||||||
pub model: AppModel,
|
|
||||||
nav: nav_bar::Model,
|
|
||||||
context_page: ContextPage,
|
|
||||||
pub config: AppConfig,
|
|
||||||
config_handler: Option<cosmic_config::Config>,
|
|
||||||
pub document_manager: DocumentManager,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl cosmic::Application for NoctuaApp {
|
|
||||||
type Executor = cosmic::SingleThreadExecutor;
|
|
||||||
type Flags = Flags;
|
|
||||||
type Message = AppMessage;
|
|
||||||
|
|
||||||
const APP_ID: &'static str = "org.codeberg.wfx.Noctua";
|
|
||||||
|
|
||||||
fn core(&self) -> &Core {
|
|
||||||
&self.core
|
|
||||||
}
|
|
||||||
|
|
||||||
fn core_mut(&mut self) -> &mut Core {
|
|
||||||
&mut self.core
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init(mut core: Core, flags: Self::Flags) -> (Self, Task<Action<Self::Message>>) {
|
|
||||||
// Load persisted config.
|
|
||||||
let (config, config_handler) =
|
|
||||||
match cosmic_config::Config::new(Self::APP_ID, AppConfig::VERSION) {
|
|
||||||
Ok(handler) => {
|
|
||||||
let config = AppConfig::get_entry(&handler).unwrap_or_default();
|
|
||||||
(config, Some(handler))
|
|
||||||
}
|
|
||||||
Err(_) => (AppConfig::default(), None),
|
|
||||||
};
|
|
||||||
|
|
||||||
let Flags::Args(args) = flags;
|
|
||||||
|
|
||||||
// Determine initial path: CLI argument takes priority.
|
|
||||||
// Fall back to configured default directory only if it exists.
|
|
||||||
let initial_path = args.file.or_else(|| {
|
|
||||||
config
|
|
||||||
.default_image_dir
|
|
||||||
.as_ref()
|
|
||||||
.filter(|p| p.exists())
|
|
||||||
.cloned()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize document manager
|
|
||||||
let mut document_manager = DocumentManager::new();
|
|
||||||
|
|
||||||
// Initialize model
|
|
||||||
let mut model = AppModel::new(config.clone());
|
|
||||||
|
|
||||||
// Load initial document if provided
|
|
||||||
if let Some(path) = initial_path {
|
|
||||||
if let Err(e) = document_manager.open_document(&path) {
|
|
||||||
log::error!("Failed to open initial path {}: {}", path.display(), e);
|
|
||||||
} else {
|
|
||||||
// Set initial view mode to Fit
|
|
||||||
model.viewport.fit_mode = ViewMode::Fit;
|
|
||||||
model.viewport.scale = 1.0;
|
|
||||||
model.reset_pan();
|
|
||||||
|
|
||||||
// Cache initial render so image is displayed immediately
|
|
||||||
if let Some(doc) = document_manager.current_document_mut() {
|
|
||||||
use crate::domain::document::core::document::Renderable;
|
|
||||||
match doc.render(model.viewport.scale as f64) {
|
|
||||||
Ok(output) => {
|
|
||||||
model.viewport.cached_image_handle = Some(output.handle);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to render initial document: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize nav bar model (required for COSMIC to show toggle icon).
|
|
||||||
let nav = nav_bar::Model::default();
|
|
||||||
|
|
||||||
// Apply persisted panel states.
|
|
||||||
core.window.show_context = config.context_drawer_visible;
|
|
||||||
|
|
||||||
// Auto-open nav bar for multi-page documents
|
|
||||||
let should_show_nav = if let Some(doc) = document_manager.current_document() {
|
|
||||||
doc.is_multi_page()
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
};
|
|
||||||
|
|
||||||
if should_show_nav {
|
|
||||||
core.nav_bar_set_toggled(true);
|
|
||||||
model.panels.left = Some(crate::ui::model::LeftPanel::Thumbnails);
|
|
||||||
} else {
|
|
||||||
core.nav_bar_set_toggled(config.nav_bar_visible);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start thumbnail generation for initial document if applicable.
|
|
||||||
let init_task = start_thumbnail_generation(&model);
|
|
||||||
|
|
||||||
(
|
|
||||||
Self {
|
|
||||||
core,
|
|
||||||
model,
|
|
||||||
nav,
|
|
||||||
context_page: ContextPage::default(),
|
|
||||||
config,
|
|
||||||
config_handler,
|
|
||||||
document_manager,
|
|
||||||
},
|
|
||||||
init_task,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_close_requested(&self, _id: window::Id) -> Option<Self::Message> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update(&mut self, message: Self::Message) -> Task<Action<Self::Message>> {
|
|
||||||
match &message {
|
|
||||||
AppMessage::ToggleNavBar => {
|
|
||||||
use crate::ui::model::LeftPanel;
|
|
||||||
|
|
||||||
self.core.nav_bar_toggle();
|
|
||||||
let is_visible = self.core.nav_bar_active();
|
|
||||||
self.config.nav_bar_visible = is_visible;
|
|
||||||
self.save_config();
|
|
||||||
|
|
||||||
if is_visible {
|
|
||||||
// Opening nav bar - show thumbnails for multi-page docs
|
|
||||||
if let Some(doc) = self.document_manager.current_document()
|
|
||||||
&& doc.is_multi_page()
|
|
||||||
{
|
|
||||||
self.model.panels.left = Some(LeftPanel::Thumbnails);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Closing nav bar - hide left panel
|
|
||||||
self.model.panels.left = None;
|
|
||||||
}
|
|
||||||
return Task::none();
|
|
||||||
}
|
|
||||||
|
|
||||||
AppMessage::OpenFormatPanel => {
|
|
||||||
// Format panel is now part of Transform mode
|
|
||||||
// Switch to Transform mode which shows format tools in right panel
|
|
||||||
self.model.mode = crate::ui::model::AppMode::Transform {
|
|
||||||
paper_format: None,
|
|
||||||
orientation: crate::ui::model::Orientation::default(),
|
|
||||||
};
|
|
||||||
|
|
||||||
return Task::none();
|
|
||||||
}
|
|
||||||
|
|
||||||
AppMessage::ToggleContextPage(page) => {
|
|
||||||
if self.context_page == *page {
|
|
||||||
self.core.window.show_context = !self.core.window.show_context;
|
|
||||||
} else {
|
|
||||||
self.context_page = *page;
|
|
||||||
self.core.window.show_context = true;
|
|
||||||
}
|
|
||||||
self.config.context_drawer_visible = self.core.window.show_context;
|
|
||||||
self.save_config();
|
|
||||||
return Task::none();
|
|
||||||
}
|
|
||||||
|
|
||||||
AppMessage::OpenPath(_) | AppMessage::NextDocument | AppMessage::PrevDocument => {
|
|
||||||
let result = update::update(self, &message);
|
|
||||||
let thumb_task = start_thumbnail_generation_task(&self.model);
|
|
||||||
return match result {
|
|
||||||
update::UpdateResult::None => thumb_task,
|
|
||||||
update::UpdateResult::Task(task) => Task::batch([task, thumb_task]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
match update::update(self, &message) {
|
|
||||||
update::UpdateResult::None => Task::none(),
|
|
||||||
update::UpdateResult::Task(task) => task,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn header_start(&self) -> Vec<Element<'_, Self::Message>> {
|
|
||||||
views::header::start(&self.model, &self.document_manager)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn header_end(&self) -> Vec<Element<'_, Self::Message>> {
|
|
||||||
views::header::end(&self.model, &self.document_manager)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> Element<'_, Self::Message> {
|
|
||||||
views::view(&self.model, &self.document_manager, &self.config)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn context_drawer(&self) -> Option<context_drawer::ContextDrawer<'_, Self::Message>> {
|
|
||||||
if !self.core.window.show_context {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(context_drawer::context_drawer(
|
|
||||||
views::panels::view(&self.model, &self.document_manager),
|
|
||||||
AppMessage::ToggleContextPage(ContextPage::Properties),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nav_model(&self) -> Option<&nav_bar::Model> {
|
|
||||||
Some(&self.nav)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nav_bar(&self) -> Option<Element<'_, Action<Self::Message>>> {
|
|
||||||
if !self.core.nav_bar_active() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
views::nav_bar(&self.model, &self.document_manager)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn footer(&self) -> Option<Element<'_, Self::Message>> {
|
|
||||||
Some(views::footer::view(&self.model, &self.document_manager))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn subscription(&self) -> Subscription<Self::Message> {
|
|
||||||
Subscription::batch([
|
|
||||||
keyboard::on_key_press(handle_key_press),
|
|
||||||
thumbnail_refresh_subscription(self),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NoctuaApp {
|
|
||||||
/// Save current config to disk.
|
|
||||||
fn save_config(&self) {
|
|
||||||
if let Some(ref handler) = self.config_handler {
|
|
||||||
let _ = self.config.write_entry(handler);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update nav bar visibility based on current document type.
|
|
||||||
pub fn update_nav_bar_for_document(&mut self) {
|
|
||||||
use crate::ui::model::LeftPanel;
|
|
||||||
|
|
||||||
if let Some(doc) = self.document_manager.current_document() {
|
|
||||||
if doc.is_multi_page() {
|
|
||||||
// Multi-page document: open nav bar and show thumbnails
|
|
||||||
self.core.nav_bar_set_toggled(true);
|
|
||||||
self.model.panels.left = Some(LeftPanel::Thumbnails);
|
|
||||||
} else {
|
|
||||||
// Single-page document: close nav bar
|
|
||||||
self.core.nav_bar_set_toggled(false);
|
|
||||||
self.model.panels.left = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Map raw key presses + modifiers into high-level application messages.
|
|
||||||
fn handle_key_press(key: Key, modifiers: Modifiers) -> Option<AppMessage> {
|
|
||||||
use AppMessage::{
|
|
||||||
PanLeft, PanRight, PanUp, PanDown, OpenFormatPanel, NextDocument, PrevDocument,
|
|
||||||
FlipHorizontal, FlipVertical, RotateCCW, RotateCW, ZoomIn, ZoomOut, ZoomReset, ZoomFit,
|
|
||||||
ToggleCropMode, ToggleScaleMode, PanReset, ToggleContextPage, ToggleNavBar, SetAsWallpaper,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle Ctrl + arrow keys for panning.
|
|
||||||
if modifiers.control() && !modifiers.shift() && !modifiers.alt() && !modifiers.logo() {
|
|
||||||
return match key.as_ref() {
|
|
||||||
Key::Named(Named::ArrowLeft) => Some(PanLeft),
|
|
||||||
Key::Named(Named::ArrowRight) => Some(PanRight),
|
|
||||||
Key::Named(Named::ArrowUp) => Some(PanUp),
|
|
||||||
Key::Named(Named::ArrowDown) => Some(PanDown),
|
|
||||||
Key::Character(ch) if ch.eq_ignore_ascii_case("f") => Some(OpenFormatPanel),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ignore key presses when command-style modifiers are pressed.
|
|
||||||
if modifiers.command() || modifiers.alt() || modifiers.logo() || modifiers.control() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
match key.as_ref() {
|
|
||||||
// Navigation with arrow keys (no modifiers).
|
|
||||||
Key::Named(Named::ArrowRight) => Some(NextDocument),
|
|
||||||
Key::Named(Named::ArrowLeft) => Some(PrevDocument),
|
|
||||||
|
|
||||||
// Transformations.
|
|
||||||
Key::Character(ch) if ch.eq_ignore_ascii_case("h") => Some(FlipHorizontal),
|
|
||||||
Key::Character(ch) if ch.eq_ignore_ascii_case("v") => Some(FlipVertical),
|
|
||||||
Key::Character(ch) if ch.eq_ignore_ascii_case("r") => {
|
|
||||||
if modifiers.shift() {
|
|
||||||
Some(RotateCCW)
|
|
||||||
} else {
|
|
||||||
Some(RotateCW)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zoom.
|
|
||||||
Key::Character("+" | "=") => Some(ZoomIn),
|
|
||||||
Key::Character("-") => Some(ZoomOut),
|
|
||||||
Key::Character("1") => Some(ZoomReset),
|
|
||||||
Key::Character(ch) if ch.eq_ignore_ascii_case("f") => Some(ZoomFit),
|
|
||||||
|
|
||||||
// Tool modes.
|
|
||||||
Key::Character(ch) if ch.eq_ignore_ascii_case("c") => Some(ToggleCropMode),
|
|
||||||
Key::Character(ch) if ch.eq_ignore_ascii_case("s") => Some(ToggleScaleMode),
|
|
||||||
|
|
||||||
// Crop mode actions (Enter/Escape handled via key press, validated in update).
|
|
||||||
Key::Named(Named::Enter) => Some(AppMessage::ApplyCrop),
|
|
||||||
Key::Named(Named::Escape) => Some(AppMessage::CancelCrop),
|
|
||||||
|
|
||||||
// Reset pan.
|
|
||||||
Key::Character("0") => Some(PanReset),
|
|
||||||
|
|
||||||
// Toggle panels.
|
|
||||||
Key::Character(ch) if ch.eq_ignore_ascii_case("i") => {
|
|
||||||
Some(ToggleContextPage(ContextPage::Properties))
|
|
||||||
}
|
|
||||||
Key::Character(ch) if ch.eq_ignore_ascii_case("n") => Some(ToggleNavBar),
|
|
||||||
|
|
||||||
// Wallpaper.
|
|
||||||
Key::Character(ch) if ch.eq_ignore_ascii_case("w") => Some(SetAsWallpaper),
|
|
||||||
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Thumbnail Helpers
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
fn start_thumbnail_generation(model: &AppModel) -> Task<Action<AppMessage>> {
|
|
||||||
start_thumbnail_generation_task(model)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn start_thumbnail_generation_task(_model: &AppModel) -> Task<Action<AppMessage>> {
|
|
||||||
// TODO: Re-enable when document is synced from DocumentManager
|
|
||||||
// if let Some(doc) = &model.document {
|
|
||||||
// let page_count = doc.page_count();
|
|
||||||
// if page_count > 0 && !doc.thumbnails_ready() {
|
|
||||||
// return Task::batch([
|
|
||||||
// Task::done(Action::App(AppMessage::GenerateThumbnailPage(0))),
|
|
||||||
// Task::done(Action::App(AppMessage::RefreshView)),
|
|
||||||
// ]);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
Task::none()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn thumbnail_refresh_subscription(_app: &NoctuaApp) -> Subscription<AppMessage> {
|
|
||||||
// TODO: Re-enable when document is synced from DocumentManager
|
|
||||||
let needs_refresh = false;
|
|
||||||
// let needs_refresh = app
|
|
||||||
// .model
|
|
||||||
// .document
|
|
||||||
// .as_ref()
|
|
||||||
// .is_some_and(|doc| doc.is_multi_page() && !doc.thumbnails_ready());
|
|
||||||
|
|
||||||
if needs_refresh {
|
|
||||||
time::every(Duration::from_millis(100)).map(|_| AppMessage::RefreshView)
|
|
||||||
} else {
|
|
||||||
Subscription::none()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/ui/components/mod.rs
|
|
||||||
//
|
|
||||||
// UI components: reusable widgets and controls.
|
|
||||||
|
|
||||||
// Crop functionality moved to ui/widgets
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/app/message.rs
|
|
||||||
//
|
|
||||||
// Application messages: events, user actions, and internal signals.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use crate::ui::widgets::DragHandle;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum AppMessage {
|
|
||||||
// File / navigation.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
OpenPath(PathBuf),
|
|
||||||
NextDocument,
|
|
||||||
PrevDocument,
|
|
||||||
GotoPage(usize),
|
|
||||||
GenerateThumbnailPage(usize),
|
|
||||||
|
|
||||||
// Transformations.
|
|
||||||
RotateCW,
|
|
||||||
RotateCCW,
|
|
||||||
FlipHorizontal,
|
|
||||||
FlipVertical,
|
|
||||||
|
|
||||||
// View / zoom.
|
|
||||||
ZoomIn,
|
|
||||||
ZoomOut,
|
|
||||||
ZoomReset,
|
|
||||||
ZoomFit,
|
|
||||||
ViewerStateChanged {
|
|
||||||
scale: f32,
|
|
||||||
offset_x: f32,
|
|
||||||
offset_y: f32,
|
|
||||||
canvas_size: cosmic::iced::Size,
|
|
||||||
image_size: cosmic::iced::Size,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Pan control.
|
|
||||||
PanLeft,
|
|
||||||
PanRight,
|
|
||||||
PanUp,
|
|
||||||
PanDown,
|
|
||||||
PanReset,
|
|
||||||
|
|
||||||
// Tool modes.
|
|
||||||
ToggleCropMode,
|
|
||||||
ToggleScaleMode,
|
|
||||||
|
|
||||||
// Crop operations.
|
|
||||||
StartCrop,
|
|
||||||
CancelCrop,
|
|
||||||
ApplyCrop,
|
|
||||||
CropDragStart {
|
|
||||||
x: f32,
|
|
||||||
y: f32,
|
|
||||||
handle: DragHandle,
|
|
||||||
},
|
|
||||||
CropDragMove {
|
|
||||||
x: f32,
|
|
||||||
y: f32,
|
|
||||||
max_x: f32,
|
|
||||||
max_y: f32,
|
|
||||||
},
|
|
||||||
|
|
||||||
CropDragEnd,
|
|
||||||
|
|
||||||
// Panels.
|
|
||||||
ToggleContextPage(crate::ui::app::ContextPage),
|
|
||||||
ToggleNavBar,
|
|
||||||
OpenFormatPanel,
|
|
||||||
|
|
||||||
// Menu.
|
|
||||||
ToggleMainMenu,
|
|
||||||
|
|
||||||
// Format operations.
|
|
||||||
SetPaperFormat(super::model::PaperFormat),
|
|
||||||
SetOrientation(super::model::Orientation),
|
|
||||||
|
|
||||||
// Metadata.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
RefreshMetadata,
|
|
||||||
|
|
||||||
// Save operations.
|
|
||||||
SaveAs,
|
|
||||||
|
|
||||||
// Wallpaper.
|
|
||||||
SetAsWallpaper,
|
|
||||||
|
|
||||||
// Errors.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
ShowError(String),
|
|
||||||
#[allow(dead_code)]
|
|
||||||
ClearError,
|
|
||||||
|
|
||||||
// UI refresh.
|
|
||||||
RefreshView,
|
|
||||||
|
|
||||||
// Fallback.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
NoOp,
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/ui/mod.rs
|
|
||||||
//
|
|
||||||
// UI layer: COSMIC application, views, and widgets.
|
|
||||||
|
|
||||||
pub mod app;
|
|
||||||
pub mod message;
|
|
||||||
pub mod model;
|
|
||||||
pub mod update;
|
|
||||||
pub mod components;
|
|
||||||
pub mod views;
|
|
||||||
pub mod widgets;
|
|
||||||
|
|
||||||
// Re-export main types
|
|
||||||
pub use app::NoctuaApp;
|
|
||||||
pub use message::AppMessage;
|
|
||||||
pub use model::AppModel;
|
|
||||||
280
src/ui/model.rs
280
src/ui/model.rs
|
|
@ -1,280 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/ui/model.rs
|
|
||||||
//
|
|
||||||
// UI state (view, tools, panels).
|
|
||||||
//
|
|
||||||
// AppModel contains ONLY UI-specific state.
|
|
||||||
// Document state lives in DocumentManager (application layer).
|
|
||||||
|
|
||||||
use cosmic::iced::Size;
|
|
||||||
|
|
||||||
use crate::ui::widgets::CropSelection;
|
|
||||||
use crate::config::AppConfig;
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// View Mode
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub enum ViewMode {
|
|
||||||
#[default]
|
|
||||||
Fit,
|
|
||||||
ActualSize,
|
|
||||||
Custom,
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Paper Format (for export/transform)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum PaperFormat {
|
|
||||||
UsLetter,
|
|
||||||
IsoA0,
|
|
||||||
IsoA1,
|
|
||||||
IsoA2,
|
|
||||||
IsoA3,
|
|
||||||
IsoA4,
|
|
||||||
IsoA5,
|
|
||||||
IsoA6,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PaperFormat {
|
|
||||||
/// Returns (width, height) in millimeters
|
|
||||||
pub fn dimensions_mm(self) -> (u32, u32) {
|
|
||||||
match self {
|
|
||||||
Self::UsLetter => (216, 279), // 8.5 x 11 inches
|
|
||||||
Self::IsoA0 => (841, 1189),
|
|
||||||
Self::IsoA1 => (594, 841),
|
|
||||||
Self::IsoA2 => (420, 594),
|
|
||||||
Self::IsoA3 => (297, 420),
|
|
||||||
Self::IsoA4 => (210, 297),
|
|
||||||
Self::IsoA5 => (148, 210),
|
|
||||||
Self::IsoA6 => (105, 148),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns display name
|
|
||||||
pub fn display_name(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::UsLetter => "US Letter",
|
|
||||||
Self::IsoA0 => "A0 (841 × 1189 mm)",
|
|
||||||
Self::IsoA1 => "A1",
|
|
||||||
Self::IsoA2 => "A2",
|
|
||||||
Self::IsoA3 => "A3",
|
|
||||||
Self::IsoA4 => "A4",
|
|
||||||
Self::IsoA5 => "A5 (148 × 210 mm)",
|
|
||||||
Self::IsoA6 => "A6",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub enum Orientation {
|
|
||||||
Horizontal,
|
|
||||||
#[default]
|
|
||||||
Vertical,
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Application Mode (combines tool + panel state)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/// Application mode - unified tool and panel state.
|
|
||||||
///
|
|
||||||
/// Each mode determines:
|
|
||||||
/// - Active tool behavior
|
|
||||||
/// - Right panel content
|
|
||||||
/// - Available shortcuts
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub enum AppMode {
|
|
||||||
/// Normal viewing mode - no active tool
|
|
||||||
View,
|
|
||||||
|
|
||||||
/// Crop mode with selection
|
|
||||||
Crop { selection: CropSelection },
|
|
||||||
|
|
||||||
/// Transform/export mode
|
|
||||||
Transform {
|
|
||||||
paper_format: Option<PaperFormat>,
|
|
||||||
orientation: Orientation,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Fullscreen mode (all panels hidden)
|
|
||||||
Fullscreen,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AppMode {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::View
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppMode {
|
|
||||||
/// Get the right panel that should be shown for this mode
|
|
||||||
pub fn right_panel(&self) -> Option<RightPanel> {
|
|
||||||
match self {
|
|
||||||
Self::View => Some(RightPanel::Properties),
|
|
||||||
Self::Crop { .. } => Some(RightPanel::CropTools),
|
|
||||||
Self::Transform { .. } => Some(RightPanel::TransformTools),
|
|
||||||
Self::Fullscreen => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if mode is an active tool (not View/Fullscreen)
|
|
||||||
pub fn is_tool_active(&self) -> bool {
|
|
||||||
matches!(self, Self::Crop { .. } | Self::Transform { .. })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Viewport (zoom, pan, canvas)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/// Viewport state - zoom, pan, canvas dimensions.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Viewport {
|
|
||||||
/// Current scale factor
|
|
||||||
pub scale: f32,
|
|
||||||
|
|
||||||
/// Pan offset X
|
|
||||||
pub pan_x: f32,
|
|
||||||
|
|
||||||
/// Pan offset Y
|
|
||||||
pub pan_y: f32,
|
|
||||||
|
|
||||||
/// Canvas size (container)
|
|
||||||
pub canvas_size: Size,
|
|
||||||
|
|
||||||
/// Image size (after scaling)
|
|
||||||
pub image_size: Size,
|
|
||||||
|
|
||||||
/// Fit mode
|
|
||||||
pub fit_mode: ViewMode,
|
|
||||||
|
|
||||||
/// Scroll container ID
|
|
||||||
pub scroll_id: cosmic::widget::Id,
|
|
||||||
|
|
||||||
/// Cached image handle for rendering (updated when document or scale changes)
|
|
||||||
pub cached_image_handle: Option<cosmic::widget::image::Handle>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Viewport {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
scale: 1.0,
|
|
||||||
pan_x: 0.0,
|
|
||||||
pan_y: 0.0,
|
|
||||||
canvas_size: Size::ZERO,
|
|
||||||
image_size: Size::ZERO,
|
|
||||||
fit_mode: ViewMode::Fit,
|
|
||||||
scroll_id: cosmic::widget::Id::new("canvas-scroll"),
|
|
||||||
cached_image_handle: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Viewport {
|
|
||||||
/// Reset pan to center
|
|
||||||
pub fn reset_pan(&mut self) {
|
|
||||||
self.pan_x = 0.0;
|
|
||||||
self.pan_y = 0.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Panel State
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/// Panel visibility state.
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct PanelState {
|
|
||||||
/// Left panel (thumbnails for multi-page)
|
|
||||||
pub left: Option<LeftPanel>,
|
|
||||||
|
|
||||||
/// Right panel (context-dependent tools/properties)
|
|
||||||
pub right: Option<RightPanel>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Left panel types
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum LeftPanel {
|
|
||||||
/// Thumbnail navigation for multi-page documents
|
|
||||||
Thumbnails,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Right panel types
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub enum RightPanel {
|
|
||||||
/// Document properties and metadata
|
|
||||||
Properties,
|
|
||||||
|
|
||||||
/// Crop mode tools
|
|
||||||
CropTools,
|
|
||||||
|
|
||||||
/// Transform/export tools
|
|
||||||
TransformTools,
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// AppModel (UI State Only)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/// UI state for the application.
|
|
||||||
///
|
|
||||||
/// Contains ONLY UI-specific state:
|
|
||||||
/// - Current mode (view/tool)
|
|
||||||
/// - Viewport (zoom/pan)
|
|
||||||
/// - Panel visibility
|
|
||||||
/// - Transient UI state (errors, menu)
|
|
||||||
///
|
|
||||||
/// Document state (current file, metadata, etc.) lives in DocumentManager!
|
|
||||||
pub struct AppModel {
|
|
||||||
/// Current application mode
|
|
||||||
pub mode: AppMode,
|
|
||||||
|
|
||||||
/// Viewport state
|
|
||||||
pub viewport: Viewport,
|
|
||||||
|
|
||||||
/// Panel visibility
|
|
||||||
pub panels: PanelState,
|
|
||||||
|
|
||||||
/// Error message to display
|
|
||||||
pub error: Option<String>,
|
|
||||||
|
|
||||||
/// Is main menu open?
|
|
||||||
pub menu_open: bool,
|
|
||||||
|
|
||||||
/// Tick counter for animations
|
|
||||||
pub tick: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppModel {
|
|
||||||
pub fn new(_config: AppConfig) -> Self {
|
|
||||||
Self {
|
|
||||||
mode: AppMode::default(),
|
|
||||||
viewport: Viewport::default(),
|
|
||||||
panels: PanelState::default(),
|
|
||||||
error: None,
|
|
||||||
menu_open: false,
|
|
||||||
tick: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set error message
|
|
||||||
pub fn set_error<S: Into<String>>(&mut self, msg: S) {
|
|
||||||
self.error = Some(msg.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear error message
|
|
||||||
pub fn clear_error(&mut self) {
|
|
||||||
self.error = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reset viewport pan to center
|
|
||||||
pub fn reset_pan(&mut self) {
|
|
||||||
self.viewport.reset_pan();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
404
src/ui/update.rs
404
src/ui/update.rs
|
|
@ -1,404 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/ui/update.rs
|
|
||||||
//
|
|
||||||
// Application update loop: applies messages to the global model state.
|
|
||||||
|
|
||||||
use cosmic::{Action, Task};
|
|
||||||
|
|
||||||
use super::NoctuaApp;
|
|
||||||
use super::message::AppMessage;
|
|
||||||
use super::model::{AppMode, ViewMode};
|
|
||||||
use crate::application::commands::transform_document::{TransformDocumentCommand, TransformOperation};
|
|
||||||
use crate::application::commands::crop_document::CropDocumentCommand;
|
|
||||||
use crate::domain::document::core::document::Renderable;
|
|
||||||
use crate::ui::widgets::{CropSelection, DragHandle};
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Update Result
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub enum UpdateResult {
|
|
||||||
None,
|
|
||||||
Task(Task<Action<AppMessage>>),
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Main Update Function
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
pub fn update(app: &mut NoctuaApp, msg: &AppMessage) -> UpdateResult {
|
|
||||||
match msg {
|
|
||||||
// ---- File / navigation ----------------------------------------------------
|
|
||||||
AppMessage::OpenPath(path) => {
|
|
||||||
if let Err(e) = app.document_manager.open_document(path) {
|
|
||||||
app.model.set_error(format!("Failed to open document: {e}"));
|
|
||||||
} else {
|
|
||||||
app.model.reset_pan();
|
|
||||||
app.model.viewport.fit_mode = ViewMode::Fit;
|
|
||||||
app.model.viewport.scale = 1.0;
|
|
||||||
cache_render(&mut app.model, &mut app.document_manager);
|
|
||||||
|
|
||||||
// Auto-toggle nav bar for multi-page documents
|
|
||||||
app.update_nav_bar_for_document();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AppMessage::NextDocument => {
|
|
||||||
// Ignore navigation in Crop mode
|
|
||||||
if !matches!(app.model.mode, AppMode::Crop { .. })
|
|
||||||
&& let Some(_path) = app.document_manager.next_document()
|
|
||||||
{
|
|
||||||
// Reset zoom when navigating to new document
|
|
||||||
app.model.viewport.scale = 1.0;
|
|
||||||
app.model.viewport.fit_mode = ViewMode::Fit;
|
|
||||||
app.model.reset_pan();
|
|
||||||
cache_render(&mut app.model, &mut app.document_manager);
|
|
||||||
|
|
||||||
// Auto-toggle nav bar for multi-page documents
|
|
||||||
app.update_nav_bar_for_document();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AppMessage::PrevDocument => {
|
|
||||||
// Ignore navigation in Crop mode
|
|
||||||
if !matches!(app.model.mode, AppMode::Crop { .. })
|
|
||||||
&& let Some(_path) = app.document_manager.previous_document()
|
|
||||||
{
|
|
||||||
// Reset zoom when navigating to new document
|
|
||||||
app.model.viewport.scale = 1.0;
|
|
||||||
app.model.viewport.fit_mode = ViewMode::Fit;
|
|
||||||
app.model.reset_pan();
|
|
||||||
cache_render(&mut app.model, &mut app.document_manager);
|
|
||||||
|
|
||||||
// Auto-toggle nav bar for multi-page documents
|
|
||||||
app.update_nav_bar_for_document();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AppMessage::GotoPage(page) => {
|
|
||||||
if let Some(doc) = app.document_manager.current_document_mut() {
|
|
||||||
if let Err(e) = doc.go_to_page(*page) {
|
|
||||||
log::error!("Failed to navigate to page {page}: {e}");
|
|
||||||
} else {
|
|
||||||
cache_render(&mut app.model, &mut app.document_manager);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Thumbnail generation -------------------------------------------------
|
|
||||||
AppMessage::GenerateThumbnailPage(_page) => {
|
|
||||||
// TODO: Thumbnail generation via DocumentManager
|
|
||||||
// Currently handled by DocumentManager.open_document()
|
|
||||||
}
|
|
||||||
|
|
||||||
AppMessage::RefreshView => {
|
|
||||||
app.model.tick += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- View / zoom ---------------------------------------------------------
|
|
||||||
AppMessage::ZoomIn => {
|
|
||||||
app.model.viewport.scale = (app.model.viewport.scale * 1.2).min(10.0);
|
|
||||||
app.model.viewport.fit_mode = ViewMode::Custom;
|
|
||||||
}
|
|
||||||
|
|
||||||
AppMessage::ZoomOut => {
|
|
||||||
app.model.viewport.scale = (app.model.viewport.scale / 1.2).max(0.1);
|
|
||||||
app.model.viewport.fit_mode = ViewMode::Custom;
|
|
||||||
}
|
|
||||||
|
|
||||||
AppMessage::ZoomReset => {
|
|
||||||
app.model.viewport.scale = 1.0;
|
|
||||||
app.model.viewport.fit_mode = ViewMode::ActualSize;
|
|
||||||
app.model.reset_pan();
|
|
||||||
}
|
|
||||||
|
|
||||||
AppMessage::ZoomFit => {
|
|
||||||
app.model.viewport.fit_mode = ViewMode::Fit;
|
|
||||||
app.model.reset_pan();
|
|
||||||
}
|
|
||||||
|
|
||||||
AppMessage::ViewerStateChanged {
|
|
||||||
scale,
|
|
||||||
offset_x,
|
|
||||||
offset_y,
|
|
||||||
canvas_size,
|
|
||||||
image_size,
|
|
||||||
} => {
|
|
||||||
// Detect scale changes (zoom vs just pan)
|
|
||||||
let old_scale = app.model.viewport.scale;
|
|
||||||
|
|
||||||
// Update model from viewer state
|
|
||||||
app.model.viewport.scale = *scale;
|
|
||||||
app.model.viewport.pan_x = *offset_x;
|
|
||||||
app.model.viewport.pan_y = *offset_y;
|
|
||||||
app.model.viewport.canvas_size = *canvas_size;
|
|
||||||
app.model.viewport.image_size = *image_size;
|
|
||||||
|
|
||||||
// If scale changed, user zoomed -> switch to Custom mode and re-render
|
|
||||||
// (Fit mode is only maintained when explicitly set via ZoomFit button)
|
|
||||||
if (old_scale - *scale).abs() > 0.001 {
|
|
||||||
app.model.viewport.fit_mode = ViewMode::Custom;
|
|
||||||
cache_render(&mut app.model, &mut app.document_manager);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Pan control ---------------------------------------------------------
|
|
||||||
AppMessage::PanLeft => {
|
|
||||||
app.model.viewport.pan_x -= 50.0;
|
|
||||||
}
|
|
||||||
AppMessage::PanRight => {
|
|
||||||
app.model.viewport.pan_x += 50.0;
|
|
||||||
}
|
|
||||||
AppMessage::PanUp => {
|
|
||||||
app.model.viewport.pan_y -= 50.0;
|
|
||||||
}
|
|
||||||
AppMessage::PanDown => {
|
|
||||||
app.model.viewport.pan_y += 50.0;
|
|
||||||
}
|
|
||||||
AppMessage::PanReset => {
|
|
||||||
app.model.reset_pan();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Tool modes ----------------------------------------------------------
|
|
||||||
AppMessage::ToggleCropMode => {
|
|
||||||
app.model.mode = match &app.model.mode {
|
|
||||||
AppMode::Crop { .. } => AppMode::View,
|
|
||||||
_ => AppMode::Crop {
|
|
||||||
selection: CropSelection::default(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
AppMessage::ToggleScaleMode => {
|
|
||||||
// Scale mode -> Transform mode
|
|
||||||
app.model.mode = match &app.model.mode {
|
|
||||||
AppMode::Transform { .. } => AppMode::View,
|
|
||||||
_ => AppMode::Transform {
|
|
||||||
paper_format: None,
|
|
||||||
orientation: Default::default(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Crop operations -----------------------------------------------------
|
|
||||||
AppMessage::StartCrop => {
|
|
||||||
if app.document_manager.current_document().is_some() {
|
|
||||||
app.model.mode = AppMode::Crop {
|
|
||||||
selection: CropSelection::default(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AppMessage::CancelCrop => {
|
|
||||||
// Only cancel if actually in Crop mode
|
|
||||||
if matches!(app.model.mode, AppMode::Crop { .. }) {
|
|
||||||
app.model.mode = AppMode::View;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AppMessage::ApplyCrop => {
|
|
||||||
if let AppMode::Crop { selection } = &app.model.mode {
|
|
||||||
// Get crop selection region
|
|
||||||
if let Some(crop_region) = selection.to_crop_region() {
|
|
||||||
// Create crop command from canvas selection
|
|
||||||
let pan_offset = cosmic::iced::Vector::new(
|
|
||||||
app.model.viewport.pan_x,
|
|
||||||
app.model.viewport.pan_y,
|
|
||||||
);
|
|
||||||
|
|
||||||
match CropDocumentCommand::from_canvas_selection(
|
|
||||||
&crop_region,
|
|
||||||
app.model.viewport.canvas_size,
|
|
||||||
app.model.viewport.image_size,
|
|
||||||
app.model.viewport.scale,
|
|
||||||
pan_offset,
|
|
||||||
) {
|
|
||||||
Ok(cmd) => {
|
|
||||||
// Execute crop command
|
|
||||||
if let Err(e) = cmd.execute(&mut app.document_manager) {
|
|
||||||
app.model.set_error(format!("Crop failed: {e}"));
|
|
||||||
} else {
|
|
||||||
// Success - exit crop mode
|
|
||||||
app.model.mode = AppMode::View;
|
|
||||||
// Reset view to fit the cropped image
|
|
||||||
app.model.viewport.scale = 1.0;
|
|
||||||
app.model.viewport.fit_mode = ViewMode::Fit;
|
|
||||||
app.model.reset_pan();
|
|
||||||
cache_render(&mut app.model, &mut app.document_manager);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
app.model.set_error(format!("Invalid crop region: {e}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
app.model.set_error("No crop region selected".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AppMessage::CropDragStart { x, y, handle } => {
|
|
||||||
if let AppMode::Crop { selection } = &mut app.model.mode {
|
|
||||||
if *handle == DragHandle::None {
|
|
||||||
selection.start_new_selection(*x, *y);
|
|
||||||
} else {
|
|
||||||
selection.start_handle_drag(*handle, *x, *y);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AppMessage::CropDragMove { x, y, max_x, max_y } => {
|
|
||||||
if let AppMode::Crop { selection } = &mut app.model.mode {
|
|
||||||
selection.update_drag(*x, *y, *max_x, *max_y);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AppMessage::CropDragEnd => {
|
|
||||||
if let AppMode::Crop { selection } = &mut app.model.mode {
|
|
||||||
selection.end_drag();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Save operations -----------------------------------------------------
|
|
||||||
AppMessage::SaveAs => {
|
|
||||||
save_as(&mut app.model);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Document transformations --------------------------------------------
|
|
||||||
AppMessage::FlipHorizontal => {
|
|
||||||
// Ignore transformations in Crop mode (would invalidate selection)
|
|
||||||
if !matches!(app.model.mode, AppMode::Crop { .. }) {
|
|
||||||
let cmd = TransformDocumentCommand::new(TransformOperation::FlipHorizontal);
|
|
||||||
if let Err(e) = cmd.execute(&mut app.document_manager) {
|
|
||||||
app.model.set_error(format!("Flip horizontal failed: {e}"));
|
|
||||||
} else {
|
|
||||||
cache_render(&mut app.model, &mut app.document_manager);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AppMessage::FlipVertical => {
|
|
||||||
// Ignore transformations in Crop mode (would invalidate selection)
|
|
||||||
if !matches!(app.model.mode, AppMode::Crop { .. }) {
|
|
||||||
let cmd = TransformDocumentCommand::new(TransformOperation::FlipVertical);
|
|
||||||
if let Err(e) = cmd.execute(&mut app.document_manager) {
|
|
||||||
app.model.set_error(format!("Flip vertical failed: {e}"));
|
|
||||||
} else {
|
|
||||||
cache_render(&mut app.model, &mut app.document_manager);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AppMessage::RotateCW => {
|
|
||||||
// Ignore transformations in Crop mode (would invalidate selection)
|
|
||||||
if !matches!(app.model.mode, AppMode::Crop { .. }) {
|
|
||||||
let cmd = TransformDocumentCommand::new(TransformOperation::RotateCw);
|
|
||||||
if let Err(e) = cmd.execute(&mut app.document_manager) {
|
|
||||||
app.model.set_error(format!("Rotate clockwise failed: {e}"));
|
|
||||||
} else {
|
|
||||||
cache_render(&mut app.model, &mut app.document_manager);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AppMessage::RotateCCW => {
|
|
||||||
// Ignore transformations in Crop mode (would invalidate selection)
|
|
||||||
if !matches!(app.model.mode, AppMode::Crop { .. }) {
|
|
||||||
let cmd = TransformDocumentCommand::new(TransformOperation::RotateCcw);
|
|
||||||
if let Err(e) = cmd.execute(&mut app.document_manager) {
|
|
||||||
app.model.set_error(format!("Rotate CCW failed: {e}"));
|
|
||||||
} else {
|
|
||||||
cache_render(&mut app.model, &mut app.document_manager);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Metadata ------------------------------------------------------------
|
|
||||||
AppMessage::RefreshMetadata => {
|
|
||||||
// Metadata is managed by DocumentManager
|
|
||||||
// Nothing to do here - views access it directly
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Format operations ---------------------------------------------------
|
|
||||||
AppMessage::SetPaperFormat(format) => {
|
|
||||||
if let AppMode::Transform { paper_format, .. } = &mut app.model.mode {
|
|
||||||
*paper_format = Some(*format);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AppMessage::SetOrientation(orientation) => {
|
|
||||||
if let AppMode::Transform {
|
|
||||||
orientation: ori, ..
|
|
||||||
} = &mut app.model.mode
|
|
||||||
{
|
|
||||||
*ori = *orientation;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Menu ----------------------------------------------------------------
|
|
||||||
AppMessage::ToggleMainMenu => {
|
|
||||||
app.model.menu_open = !app.model.menu_open;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Wallpaper -----------------------------------------------------------
|
|
||||||
AppMessage::SetAsWallpaper => {
|
|
||||||
if let Some(path) = app.document_manager.current_path() {
|
|
||||||
log::info!("Setting wallpaper to: {}", path.display());
|
|
||||||
crate::infrastructure::system::set_as_wallpaper(path);
|
|
||||||
} else {
|
|
||||||
app.model.set_error("No image loaded".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Error handling ------------------------------------------------------
|
|
||||||
AppMessage::ShowError(msg) => {
|
|
||||||
app.model.set_error(msg.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
AppMessage::ClearError => {
|
|
||||||
app.model.clear_error();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Handled elsewhere ---------------------------------------------------
|
|
||||||
AppMessage::ToggleContextPage(_)
|
|
||||||
| AppMessage::ToggleNavBar
|
|
||||||
| AppMessage::OpenFormatPanel => {
|
|
||||||
// These are handled in app.rs
|
|
||||||
}
|
|
||||||
|
|
||||||
AppMessage::NoOp => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
UpdateResult::None
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Helper Functions
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/// Cache rendered image handle in viewport for view performance.
|
|
||||||
fn cache_render(
|
|
||||||
model: &mut super::model::AppModel,
|
|
||||||
manager: &mut crate::application::DocumentManager,
|
|
||||||
) {
|
|
||||||
if let Some(doc) = manager.current_document_mut() {
|
|
||||||
match doc.render(model.viewport.scale as f64) {
|
|
||||||
Ok(output) => {
|
|
||||||
model.viewport.cached_image_handle = Some(output.handle);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to cache render: {e}");
|
|
||||||
model.viewport.cached_image_handle = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
model.viewport.cached_image_handle = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save_as(model: &mut super::model::AppModel) {
|
|
||||||
// TODO: Implement file dialog for save path
|
|
||||||
// For now, show error that this needs UI integration
|
|
||||||
model.set_error("Save As: File dialog not yet implemented".to_string());
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/ui/views/canvas.rs
|
|
||||||
//
|
|
||||||
// Render the center canvas area with the current document.
|
|
||||||
|
|
||||||
use cosmic::iced::widget::image::FilterMethod;
|
|
||||||
use cosmic::iced::{ContentFit, Length};
|
|
||||||
use cosmic::iced_widget::stack;
|
|
||||||
use cosmic::widget::{container, text};
|
|
||||||
use cosmic::Element;
|
|
||||||
|
|
||||||
use crate::ui::widgets::{crop_overlay, Viewer};
|
|
||||||
use crate::ui::model::{AppMode, ViewMode};
|
|
||||||
use crate::ui::{AppMessage, AppModel};
|
|
||||||
use crate::application::DocumentManager;
|
|
||||||
use crate::config::AppConfig;
|
|
||||||
use crate::fl;
|
|
||||||
|
|
||||||
/// Render the center canvas area with the current document.
|
|
||||||
pub fn view<'a>(
|
|
||||||
model: &'a AppModel,
|
|
||||||
_manager: &'a DocumentManager,
|
|
||||||
config: &'a AppConfig,
|
|
||||||
) -> Element<'a, AppMessage> {
|
|
||||||
// Use cached image handle from viewport
|
|
||||||
if let Some(handle) = &model.viewport.cached_image_handle {
|
|
||||||
// Determine content fit mode
|
|
||||||
let content_fit = match model.viewport.fit_mode {
|
|
||||||
ViewMode::Fit => ContentFit::Contain,
|
|
||||||
ViewMode::ActualSize | ViewMode::Custom => ContentFit::None,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if we're in crop mode (to disable pan)
|
|
||||||
let disable_pan = matches!(model.mode, AppMode::Crop { .. });
|
|
||||||
|
|
||||||
// Create image viewer
|
|
||||||
let img_viewer = Viewer::new(handle.clone())
|
|
||||||
.with_state(
|
|
||||||
model.viewport.scale,
|
|
||||||
model.viewport.pan_x,
|
|
||||||
model.viewport.pan_y,
|
|
||||||
)
|
|
||||||
.on_state_change(|scale, offset_x, offset_y, canvas_size, image_size| {
|
|
||||||
AppMessage::ViewerStateChanged {
|
|
||||||
scale,
|
|
||||||
offset_x,
|
|
||||||
offset_y,
|
|
||||||
canvas_size,
|
|
||||||
image_size,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.width(Length::Fill)
|
|
||||||
.height(Length::Fill)
|
|
||||||
.content_fit(content_fit)
|
|
||||||
.filter_method(FilterMethod::Linear)
|
|
||||||
.min_scale(config.min_scale)
|
|
||||||
.max_scale(config.max_scale)
|
|
||||||
.scale_step(config.scale_step - 1.0)
|
|
||||||
.disable_pan(disable_pan);
|
|
||||||
|
|
||||||
// Overlay crop UI when in crop mode
|
|
||||||
if let AppMode::Crop { selection } = &model.mode {
|
|
||||||
let overlay = crop_overlay(selection, config.crop_show_grid);
|
|
||||||
stack![img_viewer, overlay].into()
|
|
||||||
} else {
|
|
||||||
container(img_viewer)
|
|
||||||
.width(Length::Fill)
|
|
||||||
.height(Length::Fill)
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No document loaded
|
|
||||||
container(text(fl!("no-document")))
|
|
||||||
.width(Length::Fill)
|
|
||||||
.height(Length::Fill)
|
|
||||||
.center(Length::Fill)
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/ui/views/footer.rs
|
|
||||||
//
|
|
||||||
// Footer bar with zoom controls and document info.
|
|
||||||
|
|
||||||
use cosmic::iced::Alignment;
|
|
||||||
use cosmic::widget::{button, icon, row, text};
|
|
||||||
use cosmic::Element;
|
|
||||||
|
|
||||||
use crate::ui::model::{AppModel, ViewMode};
|
|
||||||
use crate::ui::AppMessage;
|
|
||||||
use crate::application::DocumentManager;
|
|
||||||
use crate::domain::document::core::document::Renderable;
|
|
||||||
use crate::fl;
|
|
||||||
|
|
||||||
/// Build the footer element with zoom controls and document info.
|
|
||||||
pub fn view<'a>(model: &'a AppModel, manager: &'a DocumentManager) -> Element<'a, AppMessage> {
|
|
||||||
// Zoom level display
|
|
||||||
let zoom_text = if model.viewport.fit_mode == ViewMode::Fit {
|
|
||||||
fl!("status-zoom-fit")
|
|
||||||
} else {
|
|
||||||
let percent = (model.viewport.scale * 100.0).round() as i32;
|
|
||||||
fl!("status-zoom-percent", percent: percent)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Document dimensions (from DocumentManager)
|
|
||||||
let doc_info = if let Some(doc) = manager.current_document() {
|
|
||||||
let info = doc.info();
|
|
||||||
fl!("status-doc-dimensions", width: info.width, height: info.height)
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Navigation position (from DocumentManager)
|
|
||||||
let folder_count = manager.folder_entries().len();
|
|
||||||
let nav_info = if folder_count == 0 {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
let current = manager.current_index().map_or(0, |i| i + 1);
|
|
||||||
let total = folder_count;
|
|
||||||
fl!("status-nav-position", current: current, total: total)
|
|
||||||
};
|
|
||||||
|
|
||||||
row()
|
|
||||||
.spacing(8)
|
|
||||||
.align_y(Alignment::Center)
|
|
||||||
.padding([4, 12])
|
|
||||||
// Zoom out button
|
|
||||||
.push(
|
|
||||||
button::icon(icon::from_name("zoom-out-symbolic"))
|
|
||||||
.on_press(AppMessage::ZoomOut)
|
|
||||||
.padding(4),
|
|
||||||
)
|
|
||||||
// Zoom level text
|
|
||||||
.push(text(zoom_text))
|
|
||||||
// Zoom in button
|
|
||||||
.push(
|
|
||||||
button::icon(icon::from_name("zoom-in-symbolic"))
|
|
||||||
.on_press(AppMessage::ZoomIn)
|
|
||||||
.padding(4),
|
|
||||||
)
|
|
||||||
// Zoom reset button
|
|
||||||
.push(
|
|
||||||
button::icon(icon::from_name("zoom-original-symbolic"))
|
|
||||||
.on_press(AppMessage::ZoomReset)
|
|
||||||
.padding(4),
|
|
||||||
)
|
|
||||||
// Zoom fit button
|
|
||||||
.push(
|
|
||||||
button::icon(icon::from_name("zoom-fit-best-symbolic"))
|
|
||||||
.on_press(AppMessage::ZoomFit)
|
|
||||||
.padding(4),
|
|
||||||
)
|
|
||||||
// Document dimensions
|
|
||||||
.push_maybe(if !doc_info.is_empty() {
|
|
||||||
Some(text(doc_info))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
})
|
|
||||||
// Navigation info
|
|
||||||
.push_maybe(if folder_count == 0 {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(text(nav_info))
|
|
||||||
})
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/app/view/format_panel.rs
|
|
||||||
//
|
|
||||||
// Format panel for paper format and orientation selection.
|
|
||||||
|
|
||||||
use cosmic::widget::{column, radio, text};
|
|
||||||
use cosmic::Element;
|
|
||||||
|
|
||||||
use crate::ui::model::{AppMode, AppModel, Orientation, PaperFormat};
|
|
||||||
use crate::ui::AppMessage;
|
|
||||||
use crate::fl;
|
|
||||||
|
|
||||||
/// Build the format panel view for the navigation bar.
|
|
||||||
pub fn view(model: &AppModel) -> Element<'static, AppMessage> {
|
|
||||||
// Extract values from Transform mode
|
|
||||||
let (paper_format, orientation) = match &model.mode {
|
|
||||||
AppMode::Transform {
|
|
||||||
paper_format,
|
|
||||||
orientation,
|
|
||||||
} => (*paper_format, *orientation),
|
|
||||||
_ => (None, Orientation::default()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut content = column::with_capacity(20).spacing(12).padding(16);
|
|
||||||
|
|
||||||
// --- Format Section ---
|
|
||||||
content = content
|
|
||||||
.push(text::heading(fl!("format-section-title")))
|
|
||||||
.push(text::caption(fl!("format-section-subtitle")));
|
|
||||||
|
|
||||||
// US Letter
|
|
||||||
content = content.push(
|
|
||||||
radio(
|
|
||||||
"US Letter (216 × 279 mm)",
|
|
||||||
PaperFormat::UsLetter,
|
|
||||||
paper_format,
|
|
||||||
AppMessage::SetPaperFormat,
|
|
||||||
)
|
|
||||||
.size(16),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ISO A formats
|
|
||||||
content = content
|
|
||||||
.push(text::body("ISO A"))
|
|
||||||
.push(
|
|
||||||
radio(
|
|
||||||
PaperFormat::IsoA0.display_name(),
|
|
||||||
PaperFormat::IsoA0,
|
|
||||||
paper_format,
|
|
||||||
AppMessage::SetPaperFormat,
|
|
||||||
)
|
|
||||||
.size(16),
|
|
||||||
)
|
|
||||||
.push(
|
|
||||||
radio(
|
|
||||||
PaperFormat::IsoA1.display_name(),
|
|
||||||
PaperFormat::IsoA1,
|
|
||||||
paper_format,
|
|
||||||
AppMessage::SetPaperFormat,
|
|
||||||
)
|
|
||||||
.size(16),
|
|
||||||
)
|
|
||||||
.push(
|
|
||||||
radio(
|
|
||||||
PaperFormat::IsoA2.display_name(),
|
|
||||||
PaperFormat::IsoA2,
|
|
||||||
paper_format,
|
|
||||||
AppMessage::SetPaperFormat,
|
|
||||||
)
|
|
||||||
.size(16),
|
|
||||||
)
|
|
||||||
.push(
|
|
||||||
radio(
|
|
||||||
PaperFormat::IsoA3.display_name(),
|
|
||||||
PaperFormat::IsoA3,
|
|
||||||
paper_format,
|
|
||||||
AppMessage::SetPaperFormat,
|
|
||||||
)
|
|
||||||
.size(16),
|
|
||||||
)
|
|
||||||
.push(
|
|
||||||
radio(
|
|
||||||
PaperFormat::IsoA4.display_name(),
|
|
||||||
PaperFormat::IsoA4,
|
|
||||||
paper_format,
|
|
||||||
AppMessage::SetPaperFormat,
|
|
||||||
)
|
|
||||||
.size(16),
|
|
||||||
)
|
|
||||||
.push(
|
|
||||||
radio(
|
|
||||||
PaperFormat::IsoA5.display_name(),
|
|
||||||
PaperFormat::IsoA5,
|
|
||||||
paper_format,
|
|
||||||
AppMessage::SetPaperFormat,
|
|
||||||
)
|
|
||||||
.size(16),
|
|
||||||
)
|
|
||||||
.push(
|
|
||||||
radio(
|
|
||||||
PaperFormat::IsoA6.display_name(),
|
|
||||||
PaperFormat::IsoA6,
|
|
||||||
paper_format,
|
|
||||||
AppMessage::SetPaperFormat,
|
|
||||||
)
|
|
||||||
.size(16),
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Orientation Section ---
|
|
||||||
content = content
|
|
||||||
.push(cosmic::widget::vertical_space().height(16))
|
|
||||||
.push(text::heading(fl!("orientation-section-title")));
|
|
||||||
|
|
||||||
// Horizontal
|
|
||||||
content = content.push(
|
|
||||||
radio(
|
|
||||||
"Horizontal",
|
|
||||||
Orientation::Horizontal,
|
|
||||||
Some(orientation),
|
|
||||||
AppMessage::SetOrientation,
|
|
||||||
)
|
|
||||||
.size(16),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Vertical
|
|
||||||
content = content.push(
|
|
||||||
radio(
|
|
||||||
"Vertical",
|
|
||||||
Orientation::Vertical,
|
|
||||||
Some(orientation),
|
|
||||||
AppMessage::SetOrientation,
|
|
||||||
)
|
|
||||||
.size(16),
|
|
||||||
);
|
|
||||||
|
|
||||||
content.into()
|
|
||||||
}
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/app/view/header.rs
|
|
||||||
//
|
|
||||||
// Header bar content (navigation, rotation, flip).
|
|
||||||
|
|
||||||
use cosmic::iced::Length;
|
|
||||||
use cosmic::widget::{button, horizontal_space, icon, row};
|
|
||||||
use cosmic::Element;
|
|
||||||
|
|
||||||
use crate::ui::message::AppMessage;
|
|
||||||
use crate::ui::model::AppModel;
|
|
||||||
use crate::ui::app::ContextPage;
|
|
||||||
use crate::application::DocumentManager;
|
|
||||||
use crate::fl;
|
|
||||||
|
|
||||||
/// Build the start (left) side of the header bar.
|
|
||||||
pub fn start<'a>(
|
|
||||||
_model: &'a AppModel,
|
|
||||||
manager: &'a DocumentManager,
|
|
||||||
) -> Vec<Element<'a, AppMessage>> {
|
|
||||||
let has_doc = manager.current_document().is_some();
|
|
||||||
|
|
||||||
// Left section: Panel toggle + Menu + Navigation
|
|
||||||
let left_controls = row()
|
|
||||||
.spacing(4)
|
|
||||||
.push(
|
|
||||||
button::icon(icon::from_name("view-sidebar-start-symbolic"))
|
|
||||||
.on_press(AppMessage::ToggleNavBar),
|
|
||||||
//.tooltip(fl!("tooltip-nav-toggle")),
|
|
||||||
)
|
|
||||||
// .push(
|
|
||||||
// button::icon(icon::from_name("open-menu-symbolic"))
|
|
||||||
// .on_press(AppMessage::ToggleMainMenu),
|
|
||||||
// //.tooltip(fl!("menu-main")),
|
|
||||||
// )
|
|
||||||
.push(
|
|
||||||
button::icon(icon::from_name("go-previous-symbolic"))
|
|
||||||
.on_press_maybe(has_doc.then_some(AppMessage::PrevDocument)),
|
|
||||||
//.tooltip(fl!("tooltip-nav-previous")),
|
|
||||||
)
|
|
||||||
.push(
|
|
||||||
button::icon(icon::from_name("go-next-symbolic"))
|
|
||||||
.on_press_maybe(has_doc.then_some(AppMessage::NextDocument)),
|
|
||||||
//.tooltip(fl!("tooltip-nav-next")),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Center section: Transformations
|
|
||||||
let center_controls = row()
|
|
||||||
.spacing(4)
|
|
||||||
.push(
|
|
||||||
button::icon(icon::from_name("object-rotate-left-symbolic"))
|
|
||||||
.on_press_maybe(has_doc.then_some(AppMessage::RotateCCW)),
|
|
||||||
//.tooltip(fl!("tooltip-rotate-ccw")),
|
|
||||||
)
|
|
||||||
.push(
|
|
||||||
button::icon(icon::from_name("object-rotate-right-symbolic"))
|
|
||||||
.on_press_maybe(has_doc.then_some(AppMessage::RotateCW)),
|
|
||||||
//.tooltip(fl!("tooltip-rotate-cw")),
|
|
||||||
)
|
|
||||||
.push(horizontal_space().width(Length::Fixed(12.0)))
|
|
||||||
.push(
|
|
||||||
button::icon(icon::from_name("object-flip-horizontal-symbolic"))
|
|
||||||
.on_press_maybe(has_doc.then_some(AppMessage::FlipHorizontal)),
|
|
||||||
//.tooltip(fl!("tooltip-flip-horizontal")),
|
|
||||||
)
|
|
||||||
.push(
|
|
||||||
button::icon(icon::from_name("object-flip-vertical-symbolic"))
|
|
||||||
.on_press_maybe(has_doc.then_some(AppMessage::FlipVertical)),
|
|
||||||
//.tooltip(fl!("tooltip-flip-vertical")),
|
|
||||||
);
|
|
||||||
|
|
||||||
vec![
|
|
||||||
left_controls.into(),
|
|
||||||
center_controls.into(),
|
|
||||||
horizontal_space().width(Length::Fill).into(),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the end (right) side of the header bar.
|
|
||||||
pub fn end<'a>(
|
|
||||||
_model: &'a AppModel,
|
|
||||||
_manager: &'a DocumentManager,
|
|
||||||
) -> Vec<Element<'a, AppMessage>> {
|
|
||||||
vec![
|
|
||||||
// Info panel toggle
|
|
||||||
button::icon(icon::from_name("dialog-information-symbolic"))
|
|
||||||
.on_press(AppMessage::ToggleContextPage(ContextPage::Properties))
|
|
||||||
//.tooltip(fl!("tooltip-info-panel"))
|
|
||||||
.into(),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/ui/views/meta_panel.rs
|
|
||||||
//
|
|
||||||
// Metadata and properties panel for document information.
|
|
||||||
|
|
||||||
use cosmic::iced::{Alignment, Length};
|
|
||||||
use cosmic::widget::{button, column, divider, horizontal_space, icon, row, text};
|
|
||||||
use cosmic::Element;
|
|
||||||
|
|
||||||
use crate::application::DocumentManager;
|
|
||||||
use crate::domain::document::core::document::Renderable;
|
|
||||||
use crate::ui::{AppMessage, AppModel};
|
|
||||||
use crate::fl;
|
|
||||||
|
|
||||||
/// Build the metadata/properties panel view.
|
|
||||||
pub fn view(_model: &AppModel, manager: &DocumentManager) -> Element<'static, AppMessage> {
|
|
||||||
let mut content = column::with_capacity(16).spacing(8).padding(12);
|
|
||||||
|
|
||||||
// Header with action icons
|
|
||||||
content = content.push(panel_header(manager));
|
|
||||||
|
|
||||||
// Display document metadata if available
|
|
||||||
if let Some(meta) = manager.current_metadata() {
|
|
||||||
// --- Basic Information Section ---
|
|
||||||
content = content
|
|
||||||
.push(section_header(fl!("meta-section-file")))
|
|
||||||
.push(meta_row(fl!("meta-filename"), meta.basic.file_name.clone()))
|
|
||||||
.push(meta_row(fl!("meta-format"), meta.basic.format.clone()));
|
|
||||||
|
|
||||||
// Show dimensions - original from metadata, current if transformed
|
|
||||||
let original_dims = (meta.basic.width, meta.basic.height);
|
|
||||||
let current_dims = if let Some(doc) = manager.current_document() {
|
|
||||||
let info = doc.info();
|
|
||||||
(info.width, info.height)
|
|
||||||
} else {
|
|
||||||
(0, 0)
|
|
||||||
};
|
|
||||||
|
|
||||||
if original_dims != current_dims && current_dims != (0, 0) {
|
|
||||||
// Dimensions changed (e.g., rotation) - show both
|
|
||||||
content = content.push(meta_row(
|
|
||||||
fl!("meta-dimensions"),
|
|
||||||
format!(
|
|
||||||
"{} × {} (original: {} × {})",
|
|
||||||
current_dims.0, current_dims.1, original_dims.0, original_dims.1
|
|
||||||
),
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
// No transformation or no document loaded yet
|
|
||||||
content = content.push(meta_row(
|
|
||||||
fl!("meta-dimensions"),
|
|
||||||
meta.basic.resolution_display(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
content = content
|
|
||||||
.push(meta_row(
|
|
||||||
fl!("meta-filesize"),
|
|
||||||
meta.basic.file_size_display(),
|
|
||||||
))
|
|
||||||
.push(meta_row(
|
|
||||||
fl!("meta-colortype"),
|
|
||||||
meta.basic.color_type.clone(),
|
|
||||||
));
|
|
||||||
|
|
||||||
// --- EXIF Section (if available) ---
|
|
||||||
if let Some(ref exif) = meta.exif {
|
|
||||||
let has_exif_data = exif.camera_display().is_some()
|
|
||||||
|| exif.date_time.is_some()
|
|
||||||
|| exif.exposure_time.is_some()
|
|
||||||
|| exif.f_number.is_some()
|
|
||||||
|| exif.iso.is_some()
|
|
||||||
|| exif.focal_length.is_some()
|
|
||||||
|| exif.gps_display().is_some();
|
|
||||||
|
|
||||||
if has_exif_data {
|
|
||||||
content = content
|
|
||||||
.push(divider::horizontal::light())
|
|
||||||
.push(section_header(fl!("meta-section-exif")));
|
|
||||||
|
|
||||||
if let Some(camera) = exif.camera_display() {
|
|
||||||
content = content.push(meta_row(fl!("meta-camera"), camera));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref date) = exif.date_time {
|
|
||||||
content = content.push(meta_row(fl!("meta-datetime"), date.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref exposure) = exif.exposure_time {
|
|
||||||
content = content.push(meta_row(fl!("meta-exposure"), exposure.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref fnumber) = exif.f_number {
|
|
||||||
content = content.push(meta_row(fl!("meta-aperture"), fnumber.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(iso) = exif.iso {
|
|
||||||
content = content.push(meta_row(fl!("meta-iso"), format!("ISO {}", iso)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref focal) = exif.focal_length {
|
|
||||||
content = content.push(meta_row(fl!("meta-focal"), focal.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(gps) = exif.gps_display() {
|
|
||||||
content = content.push(meta_row(fl!("meta-gps"), gps));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- File Path (at the bottom, less prominent) ---
|
|
||||||
content = content
|
|
||||||
.push(divider::horizontal::light())
|
|
||||||
.push(meta_row_small(
|
|
||||||
fl!("meta-path"),
|
|
||||||
meta.basic.file_path.clone(),
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
// No document loaded
|
|
||||||
content = content
|
|
||||||
.push(vertical_space())
|
|
||||||
.push(text::body(fl!("no-document")))
|
|
||||||
.push(vertical_space());
|
|
||||||
}
|
|
||||||
|
|
||||||
content.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Helper Components
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/// Panel header with title and action buttons.
|
|
||||||
fn panel_header(manager: &DocumentManager) -> Element<'static, AppMessage> {
|
|
||||||
let has_doc = manager.current_document().is_some();
|
|
||||||
|
|
||||||
row::with_capacity(5)
|
|
||||||
.spacing(4)
|
|
||||||
.align_y(Alignment::Center)
|
|
||||||
.padding([0, 0, 8, 0])
|
|
||||||
.push(text::title4(fl!("panel-properties")))
|
|
||||||
.push(horizontal_space().width(Length::Fill))
|
|
||||||
.push(
|
|
||||||
button::icon(icon::from_name("image-x-generic-symbolic"))
|
|
||||||
.tooltip(fl!("action-set-wallpaper"))
|
|
||||||
.padding(4)
|
|
||||||
.on_press_maybe(has_doc.then_some(AppMessage::SetAsWallpaper)),
|
|
||||||
)
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Section header for grouping metadata.
|
|
||||||
fn section_header(label: String) -> Element<'static, AppMessage> {
|
|
||||||
text::heading(label).size(14).into()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Key-value metadata row.
|
|
||||||
fn meta_row(label: String, value: String) -> Element<'static, AppMessage> {
|
|
||||||
column::with_capacity(2)
|
|
||||||
.spacing(2)
|
|
||||||
.push(text::caption(format!("{}:", label)))
|
|
||||||
.push(text::body(value))
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Less prominent metadata row (smaller text).
|
|
||||||
fn meta_row_small(label: String, value: String) -> Element<'static, AppMessage> {
|
|
||||||
column::with_capacity(2)
|
|
||||||
.spacing(2)
|
|
||||||
.push(text::caption(format!("{}:", label)))
|
|
||||||
.push(text::caption(value))
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Vertical spacer helper.
|
|
||||||
fn vertical_space() -> Element<'static, AppMessage> {
|
|
||||||
cosmic::widget::vertical_space()
|
|
||||||
.height(Length::Fixed(32.0))
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/app/view/mod.rs
|
|
||||||
//
|
|
||||||
// View module exports.
|
|
||||||
|
|
||||||
pub mod canvas;
|
|
||||||
pub mod footer;
|
|
||||||
pub mod format_panel;
|
|
||||||
pub mod header;
|
|
||||||
pub mod meta_panel;
|
|
||||||
pub mod pages_panel;
|
|
||||||
pub mod panels;
|
|
||||||
|
|
||||||
use cosmic::iced::Length;
|
|
||||||
use cosmic::widget::container;
|
|
||||||
use cosmic::{Action, Element};
|
|
||||||
|
|
||||||
use crate::ui::model::LeftPanel;
|
|
||||||
use crate::ui::{AppMessage, AppModel};
|
|
||||||
use crate::application::DocumentManager;
|
|
||||||
use crate::config::AppConfig;
|
|
||||||
|
|
||||||
/// Main application view (canvas area).
|
|
||||||
pub fn view<'a>(
|
|
||||||
model: &'a AppModel,
|
|
||||||
manager: &'a DocumentManager,
|
|
||||||
config: &'a AppConfig,
|
|
||||||
) -> Element<'a, AppMessage> {
|
|
||||||
canvas::view(model, manager, config)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Navigation bar content (left panel).
|
|
||||||
///
|
|
||||||
/// Shows different panels based on panel state:
|
|
||||||
/// - `LeftPanel::Thumbnails`: Page thumbnails (multi-page documents)
|
|
||||||
/// - `None`: Hidden
|
|
||||||
pub fn nav_bar<'a>(
|
|
||||||
model: &'a AppModel,
|
|
||||||
manager: &'a DocumentManager,
|
|
||||||
) -> Option<Element<'a, Action<AppMessage>>> {
|
|
||||||
match model.panels.left {
|
|
||||||
None => None,
|
|
||||||
Some(LeftPanel::Thumbnails) => pages_panel::view(model, manager).map(|panel| {
|
|
||||||
container(panel.map(Action::App))
|
|
||||||
.width(Length::Shrink)
|
|
||||||
.height(Length::Fill)
|
|
||||||
.max_width(250)
|
|
||||||
.into()
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/app/view/pages_panel.rs
|
|
||||||
//
|
|
||||||
// Page navigation panel for multi-page documents (PDF, multi-page TIFF, etc.).
|
|
||||||
|
|
||||||
/// Maximum width in pixels for page navigation thumbnails.
|
|
||||||
const THUMBNAIL_MAX_WIDTH: f32 = 100.0;
|
|
||||||
|
|
||||||
use cosmic::iced::{Alignment, Length};
|
|
||||||
use cosmic::widget::{button, column, container, scrollable, text};
|
|
||||||
use cosmic::widget::image as cosmic_image;
|
|
||||||
|
|
||||||
use cosmic::Element;
|
|
||||||
|
|
||||||
use crate::application::DocumentManager;
|
|
||||||
use crate::ui::{AppMessage, AppModel};
|
|
||||||
use crate::fl;
|
|
||||||
|
|
||||||
/// Build the page navigation panel view.
|
|
||||||
/// Returns None if the current document doesn't support multiple pages.
|
|
||||||
pub fn view<'a>(
|
|
||||||
_model: &'a AppModel,
|
|
||||||
manager: &'a DocumentManager,
|
|
||||||
) -> Option<Element<'a, AppMessage>> {
|
|
||||||
// Get document and check if it's multi-page
|
|
||||||
let doc = manager.current_document()?;
|
|
||||||
let page_count = doc.page_count();
|
|
||||||
|
|
||||||
if page_count <= 1 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let current_page = doc.current_page();
|
|
||||||
let loaded = doc.thumbnails_loaded();
|
|
||||||
|
|
||||||
let mut content = column::with_capacity(page_count + 1)
|
|
||||||
.spacing(12)
|
|
||||||
.padding([12, 8])
|
|
||||||
.align_x(Alignment::Center)
|
|
||||||
.width(Length::Fill);
|
|
||||||
|
|
||||||
// Show loading progress if not all thumbnails are ready.
|
|
||||||
if !doc.thumbnails_ready() {
|
|
||||||
let loading_msg = fl!("loading-thumbnails", current: loaded, total: page_count);
|
|
||||||
content = content.push(text::caption(loading_msg));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build thumbnail list for pages that are already loaded.
|
|
||||||
for page_index in 0..loaded {
|
|
||||||
let is_current = page_index == current_page;
|
|
||||||
|
|
||||||
// Get cached thumbnail handle (read-only access).
|
|
||||||
let thumbnail_element: Element<'static, AppMessage> =
|
|
||||||
if let Some(handle) = manager.get_thumbnail_handle(page_index) {
|
|
||||||
// Display the thumbnail image.
|
|
||||||
cosmic_image::Image::new(handle)
|
|
||||||
.width(Length::Fixed(THUMBNAIL_MAX_WIDTH))
|
|
||||||
.into()
|
|
||||||
} else {
|
|
||||||
// Fallback: show page number if thumbnail not yet loaded.
|
|
||||||
container(text(format!("Page {}", page_index + 1)))
|
|
||||||
.width(Length::Fixed(THUMBNAIL_MAX_WIDTH))
|
|
||||||
.height(Length::Fixed(THUMBNAIL_MAX_WIDTH * 1.4))
|
|
||||||
.center_x(Length::Fill)
|
|
||||||
.center_y(Length::Fill)
|
|
||||||
.into()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Page number label.
|
|
||||||
let page_label = text::caption(format!("{}", page_index + 1));
|
|
||||||
|
|
||||||
// Combine thumbnail and label in a column.
|
|
||||||
let page_content = column::with_capacity(2)
|
|
||||||
.spacing(4)
|
|
||||||
.align_x(Alignment::Center)
|
|
||||||
.push(thumbnail_element)
|
|
||||||
.push(page_label);
|
|
||||||
|
|
||||||
// Wrap in button for navigation.
|
|
||||||
let page_button = if is_current {
|
|
||||||
// Current page: highlighted style.
|
|
||||||
button::custom(page_content)
|
|
||||||
.class(cosmic::theme::Button::Suggested)
|
|
||||||
.padding(4)
|
|
||||||
} else {
|
|
||||||
// Other pages: clickable with standard style.
|
|
||||||
button::custom(page_content)
|
|
||||||
.class(cosmic::theme::Button::Standard)
|
|
||||||
.padding(4)
|
|
||||||
.on_press(AppMessage::GotoPage(page_index))
|
|
||||||
};
|
|
||||||
|
|
||||||
content = content.push(page_button);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap in scrollable container.
|
|
||||||
Some(
|
|
||||||
scrollable(content)
|
|
||||||
.width(Length::Shrink)
|
|
||||||
.height(Length::Fill)
|
|
||||||
.into(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/ui/views/panels.rs
|
|
||||||
//
|
|
||||||
// Panel router - delegates to specific panel views.
|
|
||||||
|
|
||||||
use cosmic::Element;
|
|
||||||
|
|
||||||
use crate::application::DocumentManager;
|
|
||||||
use crate::ui::model::{AppModel, RightPanel};
|
|
||||||
use crate::ui::AppMessage;
|
|
||||||
|
|
||||||
use super::{format_panel, meta_panel};
|
|
||||||
|
|
||||||
/// Build the right panel view based on current panel state.
|
|
||||||
///
|
|
||||||
/// Returns the appropriate panel content:
|
|
||||||
/// - `RightPanel::Properties`: Metadata and document properties (default)
|
|
||||||
/// - `RightPanel::CropTools`: Crop tool controls (TODO)
|
|
||||||
/// - `RightPanel::TransformTools`: Transform/export controls
|
|
||||||
///
|
|
||||||
/// Defaults to Properties panel if no panel is explicitly set.
|
|
||||||
pub fn view(model: &AppModel, manager: &DocumentManager) -> Element<'static, AppMessage> {
|
|
||||||
match model.panels.right.as_ref() {
|
|
||||||
Some(RightPanel::Properties) | None => meta_panel::view(model, manager),
|
|
||||||
Some(RightPanel::CropTools) => crop_tools_panel(model, manager),
|
|
||||||
Some(RightPanel::TransformTools) => format_panel::view(model),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Crop tools panel (TODO: implement dedicated crop controls).
|
|
||||||
fn crop_tools_panel(_model: &AppModel, _manager: &DocumentManager) -> Element<'static, AppMessage> {
|
|
||||||
use cosmic::widget::{column, text};
|
|
||||||
|
|
||||||
column::with_capacity(4)
|
|
||||||
.spacing(12)
|
|
||||||
.padding(12)
|
|
||||||
.push(text::title4("Crop Tools"))
|
|
||||||
.push(text::body("Crop controls will be implemented here."))
|
|
||||||
.push(text::caption(
|
|
||||||
"For now, use the crop overlay on the canvas.",
|
|
||||||
))
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
|
|
@ -1,210 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/ui/widgets/crop_model.rs
|
|
||||||
//
|
|
||||||
// Crop UI model (drag state and logic).
|
|
||||||
|
|
||||||
use crate::domain::document::operations::CropRegion;
|
|
||||||
|
|
||||||
/// Drag handle for crop selection.
|
|
||||||
///
|
|
||||||
/// Identifies which part of the selection is being dragged.
|
|
||||||
/// Pure UI concern - not part of domain.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub enum DragHandle {
|
|
||||||
#[default]
|
|
||||||
None,
|
|
||||||
TopLeft,
|
|
||||||
TopRight,
|
|
||||||
BottomLeft,
|
|
||||||
BottomRight,
|
|
||||||
Top,
|
|
||||||
Bottom,
|
|
||||||
Left,
|
|
||||||
Right,
|
|
||||||
Move,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Crop selection UI model.
|
|
||||||
///
|
|
||||||
/// Manages the interactive state of crop selection:
|
|
||||||
/// - Current selection region (in screen/canvas coordinates)
|
|
||||||
/// - Drag state (what's being dragged, where it started)
|
|
||||||
/// - Drag logic (how to update region based on handle type)
|
|
||||||
///
|
|
||||||
/// This is UI-specific logic, not domain logic!
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct CropSelection {
|
|
||||||
/// Current selection region as (x, y, width, height) in screen coordinates
|
|
||||||
pub region: Option<(f32, f32, f32, f32)>,
|
|
||||||
|
|
||||||
/// Is user currently dragging?
|
|
||||||
pub is_dragging: bool,
|
|
||||||
|
|
||||||
/// Which handle/part is being dragged?
|
|
||||||
pub drag_handle: DragHandle,
|
|
||||||
|
|
||||||
/// Where did the drag start? (for delta calculation)
|
|
||||||
drag_start: Option<(f32, f32)>,
|
|
||||||
|
|
||||||
/// What was the region when drag started? (for resize calculation)
|
|
||||||
drag_start_region: Option<(f32, f32, f32, f32)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CropSelection {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start a new selection (user clicks on empty area).
|
|
||||||
pub fn start_new_selection(&mut self, x: f32, y: f32) {
|
|
||||||
self.region = Some((x, y, 0.0, 0.0));
|
|
||||||
self.is_dragging = true;
|
|
||||||
self.drag_handle = DragHandle::None;
|
|
||||||
self.drag_start = Some((x, y));
|
|
||||||
self.drag_start_region = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start dragging a handle (user clicks on existing selection).
|
|
||||||
pub fn start_handle_drag(&mut self, handle: DragHandle, x: f32, y: f32) {
|
|
||||||
self.is_dragging = true;
|
|
||||||
self.drag_handle = handle;
|
|
||||||
self.drag_start = Some((x, y));
|
|
||||||
self.drag_start_region = self.region;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update selection during drag.
|
|
||||||
pub fn update_drag(&mut self, x: f32, y: f32, img_width: f32, img_height: f32) {
|
|
||||||
if !self.is_dragging {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
match self.drag_handle {
|
|
||||||
DragHandle::None => {
|
|
||||||
// Creating new selection - expand from start point
|
|
||||||
if let Some((start_x, start_y)) = self.drag_start {
|
|
||||||
let min_x = start_x.min(x).max(0.0);
|
|
||||||
let min_y = start_y.min(y).max(0.0);
|
|
||||||
let max_x = start_x.max(x).min(img_width);
|
|
||||||
let max_y = start_y.max(y).min(img_height);
|
|
||||||
|
|
||||||
self.region = Some((min_x, min_y, max_x - min_x, max_y - min_y));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DragHandle::Move => {
|
|
||||||
// Moving entire selection
|
|
||||||
if let (Some((start_x, start_y)), Some((rx, ry, rw, rh))) =
|
|
||||||
(self.drag_start, self.drag_start_region)
|
|
||||||
{
|
|
||||||
let dx = x - start_x;
|
|
||||||
let dy = y - start_y;
|
|
||||||
let new_x = (rx + dx).max(0.0).min(img_width - rw);
|
|
||||||
let new_y = (ry + dy).max(0.0).min(img_height - rh);
|
|
||||||
self.region = Some((new_x, new_y, rw, rh));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// Resizing from edge/corner
|
|
||||||
if let Some((rx, ry, rw, rh)) = self.drag_start_region {
|
|
||||||
let (new_x, new_y, new_w, new_h) =
|
|
||||||
self.resize_region(rx, ry, rw, rh, x, y, img_width, img_height);
|
|
||||||
self.region = Some((new_x, new_y, new_w, new_h));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resize region based on which handle is being dragged.
|
|
||||||
fn resize_region(
|
|
||||||
&self,
|
|
||||||
rx: f32,
|
|
||||||
ry: f32,
|
|
||||||
rw: f32,
|
|
||||||
rh: f32,
|
|
||||||
x: f32,
|
|
||||||
y: f32,
|
|
||||||
img_width: f32,
|
|
||||||
img_height: f32,
|
|
||||||
) -> (f32, f32, f32, f32) {
|
|
||||||
const MIN_SIZE: f32 = 10.0;
|
|
||||||
|
|
||||||
let right = rx + rw;
|
|
||||||
let bottom = ry + rh;
|
|
||||||
let x = x.max(0.0).min(img_width);
|
|
||||||
let y = y.max(0.0).min(img_height);
|
|
||||||
|
|
||||||
match self.drag_handle {
|
|
||||||
DragHandle::TopLeft => {
|
|
||||||
let new_x = x.min(right - MIN_SIZE);
|
|
||||||
let new_y = y.min(bottom - MIN_SIZE);
|
|
||||||
(new_x, new_y, right - new_x, bottom - new_y)
|
|
||||||
}
|
|
||||||
DragHandle::TopRight => {
|
|
||||||
let new_right = x.max(rx + MIN_SIZE);
|
|
||||||
let new_y = y.min(bottom - MIN_SIZE);
|
|
||||||
(rx, new_y, new_right - rx, bottom - new_y)
|
|
||||||
}
|
|
||||||
DragHandle::BottomLeft => {
|
|
||||||
let new_x = x.min(right - MIN_SIZE);
|
|
||||||
let new_bottom = y.max(ry + MIN_SIZE);
|
|
||||||
(new_x, ry, right - new_x, new_bottom - ry)
|
|
||||||
}
|
|
||||||
DragHandle::BottomRight => {
|
|
||||||
let new_right = x.max(rx + MIN_SIZE);
|
|
||||||
let new_bottom = y.max(ry + MIN_SIZE);
|
|
||||||
(rx, ry, new_right - rx, new_bottom - ry)
|
|
||||||
}
|
|
||||||
DragHandle::Top => {
|
|
||||||
let new_y = y.min(bottom - MIN_SIZE);
|
|
||||||
(rx, new_y, rw, bottom - new_y)
|
|
||||||
}
|
|
||||||
DragHandle::Bottom => {
|
|
||||||
let new_bottom = y.max(ry + MIN_SIZE);
|
|
||||||
(rx, ry, rw, new_bottom - ry)
|
|
||||||
}
|
|
||||||
DragHandle::Left => {
|
|
||||||
let new_x = x.min(right - MIN_SIZE);
|
|
||||||
(new_x, ry, right - new_x, rh)
|
|
||||||
}
|
|
||||||
DragHandle::Right => {
|
|
||||||
let new_right = x.max(rx + MIN_SIZE);
|
|
||||||
(rx, ry, new_right - rx, rh)
|
|
||||||
}
|
|
||||||
_ => (rx, ry, rw, rh),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// End drag operation.
|
|
||||||
pub fn end_drag(&mut self) {
|
|
||||||
self.is_dragging = false;
|
|
||||||
self.drag_start = None;
|
|
||||||
self.drag_start_region = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reset selection (cancel).
|
|
||||||
pub fn reset(&mut self) {
|
|
||||||
*self = Self::default();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if there's a valid selection.
|
|
||||||
pub fn has_selection(&self) -> bool {
|
|
||||||
self.region
|
|
||||||
.map(|(_, _, w, h)| w > 1.0 && h > 1.0)
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert to domain CropRegion for actual crop operation.
|
|
||||||
pub fn to_crop_region(&self) -> Option<CropRegion> {
|
|
||||||
self.region.and_then(|(x, y, w, h)| {
|
|
||||||
if w > 1.0 && h > 1.0 {
|
|
||||||
Some(CropRegion {
|
|
||||||
x: x as u32,
|
|
||||||
y: y as u32,
|
|
||||||
width: w as u32,
|
|
||||||
height: h as u32,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,404 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/ui/widgets/crop_overlay.rs
|
|
||||||
//
|
|
||||||
// Simple crop overlay.
|
|
||||||
|
|
||||||
use cosmic::{
|
|
||||||
Element, Renderer,
|
|
||||||
iced::{
|
|
||||||
Color, Length, Point, Rectangle, Size,
|
|
||||||
advanced::{
|
|
||||||
Clipboard, Layout, Shell, Widget,
|
|
||||||
layout::{Limits, Node},
|
|
||||||
renderer::{Quad, Renderer as QuadRenderer},
|
|
||||||
widget::Tree,
|
|
||||||
},
|
|
||||||
event::{Event, Status},
|
|
||||||
mouse::{self, Button, Cursor},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::ui::widgets::crop_model::{CropSelection, DragHandle};
|
|
||||||
use crate::ui::AppMessage;
|
|
||||||
|
|
||||||
const HANDLE_SIZE: f32 = 12.0;
|
|
||||||
const HANDLE_HIT_SIZE: f32 = 24.0;
|
|
||||||
const OVERLAY_COLOR: Color = Color::from_rgba(0.0, 0.0, 0.0, 0.5);
|
|
||||||
const HANDLE_COLOR: Color = Color::WHITE;
|
|
||||||
const BORDER_COLOR: Color = Color::WHITE;
|
|
||||||
const BORDER_WIDTH: f32 = 2.0;
|
|
||||||
|
|
||||||
pub struct CropOverlay {
|
|
||||||
selection: CropSelection,
|
|
||||||
show_grid: bool,
|
|
||||||
last_click: Option<std::time::Instant>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CropOverlay {
|
|
||||||
pub fn new(selection: &CropSelection, show_grid: bool) -> Self {
|
|
||||||
Self {
|
|
||||||
selection: selection.clone(),
|
|
||||||
show_grid,
|
|
||||||
last_click: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hit_test_handle(&self, point: Point) -> DragHandle {
|
|
||||||
let Some((x, y, w, h)) = self.selection.region else {
|
|
||||||
return DragHandle::None;
|
|
||||||
};
|
|
||||||
|
|
||||||
let handles = [
|
|
||||||
(Point::new(x, y), DragHandle::TopLeft),
|
|
||||||
(Point::new(x + w, y), DragHandle::TopRight),
|
|
||||||
(Point::new(x, y + h), DragHandle::BottomLeft),
|
|
||||||
(Point::new(x + w, y + h), DragHandle::BottomRight),
|
|
||||||
(Point::new(x + w / 2.0, y), DragHandle::Top),
|
|
||||||
(Point::new(x + w / 2.0, y + h), DragHandle::Bottom),
|
|
||||||
(Point::new(x, y + h / 2.0), DragHandle::Left),
|
|
||||||
(Point::new(x + w, y + h / 2.0), DragHandle::Right),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (pos, handle) in handles {
|
|
||||||
if point_in_handle(point, pos) {
|
|
||||||
return handle;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if point.x >= x && point.x <= x + w && point.y >= y && point.y <= y + h {
|
|
||||||
return DragHandle::Move;
|
|
||||||
}
|
|
||||||
|
|
||||||
DragHandle::None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_overlay(&self, renderer: &mut Renderer, bounds: Rectangle) {
|
|
||||||
let Some((x, y, w, h)) = self.selection.region else {
|
|
||||||
draw_quad(renderer, bounds, OVERLAY_COLOR);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert relative coords to absolute screen coords
|
|
||||||
let abs_x = bounds.x + x;
|
|
||||||
let abs_y = bounds.y + y;
|
|
||||||
let abs_right = abs_x + w;
|
|
||||||
let abs_bottom = abs_y + h;
|
|
||||||
|
|
||||||
// Top
|
|
||||||
if abs_y > bounds.y {
|
|
||||||
draw_quad(
|
|
||||||
renderer,
|
|
||||||
Rectangle::new(
|
|
||||||
Point::new(bounds.x, bounds.y),
|
|
||||||
Size::new(bounds.width, abs_y - bounds.y),
|
|
||||||
),
|
|
||||||
OVERLAY_COLOR,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bottom
|
|
||||||
if abs_bottom < bounds.y + bounds.height {
|
|
||||||
draw_quad(
|
|
||||||
renderer,
|
|
||||||
Rectangle::new(
|
|
||||||
Point::new(bounds.x, abs_bottom),
|
|
||||||
Size::new(bounds.width, bounds.y + bounds.height - abs_bottom),
|
|
||||||
),
|
|
||||||
OVERLAY_COLOR,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Left
|
|
||||||
if abs_x > bounds.x {
|
|
||||||
draw_quad(
|
|
||||||
renderer,
|
|
||||||
Rectangle::new(
|
|
||||||
Point::new(bounds.x, abs_y),
|
|
||||||
Size::new(abs_x - bounds.x, h),
|
|
||||||
),
|
|
||||||
OVERLAY_COLOR,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Right
|
|
||||||
if abs_right < bounds.x + bounds.width {
|
|
||||||
draw_quad(
|
|
||||||
renderer,
|
|
||||||
Rectangle::new(
|
|
||||||
Point::new(abs_right, abs_y),
|
|
||||||
Size::new(bounds.x + bounds.width - abs_right, h),
|
|
||||||
),
|
|
||||||
OVERLAY_COLOR,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_border(&self, renderer: &mut Renderer, bounds: Rectangle) {
|
|
||||||
let Some((x, y, w, h)) = self.selection.region else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add bounds offset
|
|
||||||
let abs_x = bounds.x + x;
|
|
||||||
let abs_y = bounds.y + y;
|
|
||||||
|
|
||||||
// Top
|
|
||||||
draw_quad(
|
|
||||||
renderer,
|
|
||||||
Rectangle::new(Point::new(abs_x, abs_y), Size::new(w, BORDER_WIDTH)),
|
|
||||||
BORDER_COLOR,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Bottom
|
|
||||||
draw_quad(
|
|
||||||
renderer,
|
|
||||||
Rectangle::new(
|
|
||||||
Point::new(abs_x, abs_y + h - BORDER_WIDTH),
|
|
||||||
Size::new(w, BORDER_WIDTH),
|
|
||||||
),
|
|
||||||
BORDER_COLOR,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Left
|
|
||||||
draw_quad(
|
|
||||||
renderer,
|
|
||||||
Rectangle::new(Point::new(abs_x, abs_y), Size::new(BORDER_WIDTH, h)),
|
|
||||||
BORDER_COLOR,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Right
|
|
||||||
draw_quad(
|
|
||||||
renderer,
|
|
||||||
Rectangle::new(
|
|
||||||
Point::new(abs_x + w - BORDER_WIDTH, abs_y),
|
|
||||||
Size::new(BORDER_WIDTH, h),
|
|
||||||
),
|
|
||||||
BORDER_COLOR,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_handles(&self, renderer: &mut Renderer, bounds: Rectangle) {
|
|
||||||
let Some((x, y, w, h)) = self.selection.region else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let half = HANDLE_SIZE / 2.0;
|
|
||||||
|
|
||||||
// Add bounds offset
|
|
||||||
let abs_x = bounds.x + x;
|
|
||||||
let abs_y = bounds.y + y;
|
|
||||||
|
|
||||||
let handles = [
|
|
||||||
Point::new(abs_x, abs_y),
|
|
||||||
Point::new(abs_x + w, abs_y),
|
|
||||||
Point::new(abs_x, abs_y + h),
|
|
||||||
Point::new(abs_x + w, abs_y + h),
|
|
||||||
Point::new(abs_x + w / 2.0, abs_y),
|
|
||||||
Point::new(abs_x + w / 2.0, abs_y + h),
|
|
||||||
Point::new(abs_x, abs_y + h / 2.0),
|
|
||||||
Point::new(abs_x + w, abs_y + h / 2.0),
|
|
||||||
];
|
|
||||||
|
|
||||||
for pos in handles {
|
|
||||||
draw_quad(
|
|
||||||
renderer,
|
|
||||||
Rectangle::new(
|
|
||||||
Point::new(pos.x - half, pos.y - half),
|
|
||||||
Size::new(HANDLE_SIZE, HANDLE_SIZE),
|
|
||||||
),
|
|
||||||
HANDLE_COLOR,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_grid(&self, renderer: &mut Renderer, bounds: Rectangle) {
|
|
||||||
if !self.show_grid {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some((x, y, w, h)) = self.selection.region else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
if w <= 10.0 || h <= 10.0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add bounds offset
|
|
||||||
let abs_x = bounds.x + x;
|
|
||||||
let abs_y = bounds.y + y;
|
|
||||||
|
|
||||||
let grid_color = Color::from_rgba(1.0, 1.0, 1.0, 0.3);
|
|
||||||
let third_w = w / 3.0;
|
|
||||||
let third_h = h / 3.0;
|
|
||||||
|
|
||||||
// 2 vertical
|
|
||||||
for i in 1..3 {
|
|
||||||
let line_x = abs_x + third_w * i as f32;
|
|
||||||
draw_quad(
|
|
||||||
renderer,
|
|
||||||
Rectangle::new(Point::new(line_x, abs_y), Size::new(1.0, h)),
|
|
||||||
grid_color,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2 horizontal
|
|
||||||
for i in 1..3 {
|
|
||||||
let line_y = abs_y + third_h * i as f32;
|
|
||||||
draw_quad(
|
|
||||||
renderer,
|
|
||||||
Rectangle::new(Point::new(abs_x, line_y), Size::new(w, 1.0)),
|
|
||||||
grid_color,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Widget<AppMessage, cosmic::Theme, Renderer> for CropOverlay {
|
|
||||||
fn size(&self) -> Size<Length> {
|
|
||||||
Size::new(Length::Fill, Length::Fill)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn layout(&self, _tree: &mut Tree, _renderer: &Renderer, limits: &Limits) -> Node {
|
|
||||||
Node::new(limits.max())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw(
|
|
||||||
&self,
|
|
||||||
_tree: &Tree,
|
|
||||||
renderer: &mut Renderer,
|
|
||||||
_theme: &cosmic::Theme,
|
|
||||||
_style: &cosmic::iced::advanced::renderer::Style,
|
|
||||||
layout: Layout<'_>,
|
|
||||||
_cursor: Cursor,
|
|
||||||
_viewport: &Rectangle,
|
|
||||||
) {
|
|
||||||
let bounds = layout.bounds();
|
|
||||||
|
|
||||||
self.draw_overlay(renderer, bounds);
|
|
||||||
self.draw_border(renderer, bounds);
|
|
||||||
self.draw_handles(renderer, bounds);
|
|
||||||
self.draw_grid(renderer, bounds);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_event(
|
|
||||||
&mut self,
|
|
||||||
_tree: &mut Tree,
|
|
||||||
event: Event,
|
|
||||||
layout: Layout<'_>,
|
|
||||||
cursor: Cursor,
|
|
||||||
_renderer: &Renderer,
|
|
||||||
_clipboard: &mut dyn Clipboard,
|
|
||||||
shell: &mut Shell<'_, AppMessage>,
|
|
||||||
_viewport: &Rectangle,
|
|
||||||
) -> Status {
|
|
||||||
let bounds = layout.bounds();
|
|
||||||
|
|
||||||
match event {
|
|
||||||
Event::Mouse(mouse::Event::ButtonPressed(Button::Left)) => {
|
|
||||||
if let Some(pos) = cursor.position_in(bounds) {
|
|
||||||
let handle = self.hit_test_handle(pos);
|
|
||||||
|
|
||||||
if handle == DragHandle::Move {
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
let now = Instant::now();
|
|
||||||
if let Some(last) = self.last_click {
|
|
||||||
if now.duration_since(last) < Duration::from_millis(400) {
|
|
||||||
shell.publish(AppMessage::ApplyCrop);
|
|
||||||
self.last_click = None;
|
|
||||||
return Status::Captured;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.last_click = Some(now);
|
|
||||||
}
|
|
||||||
|
|
||||||
shell.publish(AppMessage::CropDragStart {
|
|
||||||
x: pos.x,
|
|
||||||
y: pos.y,
|
|
||||||
handle,
|
|
||||||
});
|
|
||||||
return Status::Captured;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Event::Mouse(mouse::Event::CursorMoved { .. }) => {
|
|
||||||
if self.selection.is_dragging {
|
|
||||||
if let Some(pos) = cursor.position_in(bounds) {
|
|
||||||
shell.publish(AppMessage::CropDragMove {
|
|
||||||
x: pos.x,
|
|
||||||
y: pos.y,
|
|
||||||
max_x: bounds.width,
|
|
||||||
max_y: bounds.height,
|
|
||||||
});
|
|
||||||
return Status::Captured;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Event::Mouse(mouse::Event::ButtonReleased(Button::Left)) => {
|
|
||||||
if self.selection.is_dragging {
|
|
||||||
shell.publish(AppMessage::CropDragEnd);
|
|
||||||
return Status::Captured;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
Status::Ignored
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mouse_interaction(
|
|
||||||
&self,
|
|
||||||
_tree: &Tree,
|
|
||||||
layout: Layout<'_>,
|
|
||||||
cursor: Cursor,
|
|
||||||
_viewport: &Rectangle,
|
|
||||||
_renderer: &Renderer,
|
|
||||||
) -> mouse::Interaction {
|
|
||||||
let bounds = layout.bounds();
|
|
||||||
|
|
||||||
if let Some(pos) = cursor.position_in(bounds) {
|
|
||||||
let handle = self.hit_test_handle(pos);
|
|
||||||
return match handle {
|
|
||||||
DragHandle::TopLeft | DragHandle::BottomRight => {
|
|
||||||
mouse::Interaction::ResizingDiagonallyDown
|
|
||||||
}
|
|
||||||
DragHandle::TopRight | DragHandle::BottomLeft => {
|
|
||||||
mouse::Interaction::ResizingDiagonallyUp
|
|
||||||
}
|
|
||||||
DragHandle::Top | DragHandle::Bottom => mouse::Interaction::ResizingVertically,
|
|
||||||
DragHandle::Left | DragHandle::Right => mouse::Interaction::ResizingHorizontally,
|
|
||||||
DragHandle::Move => mouse::Interaction::Grabbing,
|
|
||||||
DragHandle::None => mouse::Interaction::Crosshair,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
mouse::Interaction::None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> From<CropOverlay> for Element<'a, AppMessage> {
|
|
||||||
fn from(widget: CropOverlay) -> Self {
|
|
||||||
Element::new(widget)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn point_in_handle(point: Point, handle_center: Point) -> bool {
|
|
||||||
let half = HANDLE_HIT_SIZE / 2.0;
|
|
||||||
point.x >= handle_center.x - half
|
|
||||||
&& point.x <= handle_center.x + half
|
|
||||||
&& point.y >= handle_center.y - half
|
|
||||||
&& point.y <= handle_center.y + half
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_quad(renderer: &mut Renderer, bounds: Rectangle, color: Color) {
|
|
||||||
renderer.fill_quad(
|
|
||||||
Quad {
|
|
||||||
bounds,
|
|
||||||
..Quad::default()
|
|
||||||
},
|
|
||||||
color,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn crop_overlay<'a>(selection: &CropSelection, show_grid: bool) -> Element<'a, AppMessage> {
|
|
||||||
CropOverlay::new(selection, show_grid).into()
|
|
||||||
}
|
|
||||||
|
|
@ -1,559 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/app/view/image_viewer.rs
|
|
||||||
//
|
|
||||||
// Zoom and pan image viewer widget with external state control.
|
|
||||||
// Forked from cosmic::iced to support external state control.
|
|
||||||
|
|
||||||
use cosmic::iced::advanced::image as img_renderer;
|
|
||||||
use cosmic::iced::advanced::layout;
|
|
||||||
use cosmic::iced::advanced::renderer;
|
|
||||||
use cosmic::iced::advanced::widget::tree::{self, Tree};
|
|
||||||
use cosmic::iced::advanced::widget::Widget;
|
|
||||||
use cosmic::iced::advanced::{Clipboard, Layout, Shell};
|
|
||||||
use cosmic::iced::event::{self, Event};
|
|
||||||
use cosmic::iced::mouse;
|
|
||||||
use cosmic::iced::widget::image::FilterMethod;
|
|
||||||
use cosmic::iced::{ContentFit, Element, Length, Pixels, Point, Radians, Rectangle, Size, Vector};
|
|
||||||
|
|
||||||
/// Tolerance for scale comparisons in widget state synchronization.
|
|
||||||
const SCALE_EPSILON: f32 = 0.0001;
|
|
||||||
|
|
||||||
/// Tolerance for offset comparisons in widget state synchronization.
|
|
||||||
const OFFSET_EPSILON: f32 = 0.01;
|
|
||||||
|
|
||||||
/// Callback type for notifying viewer state changes (scale, `offset_x`, `offset_y`, `canvas_size`, `image_size`).
|
|
||||||
type StateChangeCallback<Message> = Box<dyn Fn(f32, f32, f32, Size, Size) -> Message>;
|
|
||||||
|
|
||||||
/// A frame that displays an image with the ability to zoom in/out and pan.
|
|
||||||
#[allow(missing_debug_implementations)]
|
|
||||||
pub struct Viewer<Handle, Message> {
|
|
||||||
padding: f32,
|
|
||||||
width: Length,
|
|
||||||
height: Length,
|
|
||||||
min_scale: f32,
|
|
||||||
max_scale: f32,
|
|
||||||
scale_step: f32,
|
|
||||||
handle: Handle,
|
|
||||||
filter_method: FilterMethod,
|
|
||||||
content_fit: ContentFit,
|
|
||||||
/// Optional external state to override internal state (scale, offset)
|
|
||||||
external_state: Option<(f32, Vector)>,
|
|
||||||
/// Optional callback to notify state changes
|
|
||||||
on_state_change: Option<StateChangeCallback<Message>>,
|
|
||||||
/// Disable pan interaction (for crop mode)
|
|
||||||
disable_pan: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<Handle, Message> Viewer<Handle, Message> {
|
|
||||||
/// Creates a new [`Viewer`] with the given handle.
|
|
||||||
pub fn new<T: Into<Handle>>(handle: T) -> Self {
|
|
||||||
Viewer {
|
|
||||||
handle: handle.into(),
|
|
||||||
padding: 0.0,
|
|
||||||
width: Length::Shrink,
|
|
||||||
height: Length::Shrink,
|
|
||||||
min_scale: 0.25,
|
|
||||||
max_scale: 10.0,
|
|
||||||
scale_step: 0.10,
|
|
||||||
filter_method: FilterMethod::default(),
|
|
||||||
content_fit: ContentFit::default(),
|
|
||||||
external_state: None,
|
|
||||||
on_state_change: None,
|
|
||||||
disable_pan: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set external state to control zoom and pan from outside.
|
|
||||||
/// This allows keyboard/button controls to override the internal state.
|
|
||||||
pub fn with_state(mut self, scale: f32, offset_x: f32, offset_y: f32) -> Self {
|
|
||||||
self.external_state = Some((scale, Vector::new(offset_x, offset_y)));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set a callback to be notified when the state changes (for mouse interaction).
|
|
||||||
pub fn on_state_change<F>(mut self, f: F) -> Self
|
|
||||||
where
|
|
||||||
F: 'static + Fn(f32, f32, f32, Size, Size) -> Message,
|
|
||||||
{
|
|
||||||
self.on_state_change = Some(Box::new(f));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Disable pan interaction (useful when overlaying crop tools).
|
|
||||||
pub fn disable_pan(mut self, disable: bool) -> Self {
|
|
||||||
self.disable_pan = disable;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the [`FilterMethod`] of the [`Viewer`].
|
|
||||||
pub fn filter_method(mut self, filter_method: FilterMethod) -> Self {
|
|
||||||
self.filter_method = filter_method;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the [`ContentFit`] of the [`Viewer`].
|
|
||||||
pub fn content_fit(mut self, content_fit: ContentFit) -> Self {
|
|
||||||
self.content_fit = content_fit;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the padding of the [`Viewer`].
|
|
||||||
pub fn padding(mut self, padding: impl Into<Pixels>) -> Self {
|
|
||||||
self.padding = padding.into().0;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the width of the [`Viewer`].
|
|
||||||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
|
||||||
self.width = width.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the height of the [`Viewer`].
|
|
||||||
pub fn height(mut self, height: impl Into<Length>) -> Self {
|
|
||||||
self.height = height.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the max scale applied to the image of the [`Viewer`].
|
|
||||||
///
|
|
||||||
/// Default is `10.0`
|
|
||||||
pub fn max_scale(mut self, max_scale: f32) -> Self {
|
|
||||||
self.max_scale = max_scale;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the min scale applied to the image of the [`Viewer`].
|
|
||||||
///
|
|
||||||
/// Default is `0.25`
|
|
||||||
pub fn min_scale(mut self, min_scale: f32) -> Self {
|
|
||||||
self.min_scale = min_scale;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the percentage the image of the [`Viewer`] will be scaled by
|
|
||||||
/// when zoomed in / out.
|
|
||||||
///
|
|
||||||
/// Default is `0.10`
|
|
||||||
pub fn scale_step(mut self, scale_step: f32) -> Self {
|
|
||||||
self.scale_step = scale_step;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<Message, Theme, Renderer, Handle> Widget<Message, Theme, Renderer> for Viewer<Handle, Message>
|
|
||||||
where
|
|
||||||
Renderer: img_renderer::Renderer<Handle = Handle>,
|
|
||||||
Handle: Clone,
|
|
||||||
Message: Clone,
|
|
||||||
{
|
|
||||||
fn tag(&self) -> tree::Tag {
|
|
||||||
tree::Tag::of::<State>()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn state(&self) -> tree::State {
|
|
||||||
let mut state = State::new();
|
|
||||||
// Apply external state if provided at creation
|
|
||||||
if let Some((scale, offset)) = self.external_state {
|
|
||||||
state.scale = scale;
|
|
||||||
state.current_offset = offset;
|
|
||||||
state.starting_offset = offset;
|
|
||||||
}
|
|
||||||
tree::State::new(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn diff(&mut self, tree: &mut Tree) {
|
|
||||||
// Sync external state into internal state when user is not dragging
|
|
||||||
if let Some((ext_scale, ext_offset)) = self.external_state {
|
|
||||||
let state = tree.state.downcast_mut::<State>();
|
|
||||||
|
|
||||||
// Only apply external state if user is not currently dragging
|
|
||||||
if !state.is_cursor_grabbed() {
|
|
||||||
// Check if external state differs significantly from current state
|
|
||||||
let scale_changed = (state.scale - ext_scale).abs() > SCALE_EPSILON;
|
|
||||||
let offset_changed = (state.current_offset.x - ext_offset.x).abs() > OFFSET_EPSILON
|
|
||||||
|| (state.current_offset.y - ext_offset.y).abs() > OFFSET_EPSILON;
|
|
||||||
|
|
||||||
if scale_changed || offset_changed {
|
|
||||||
state.scale = ext_scale;
|
|
||||||
state.current_offset = ext_offset;
|
|
||||||
state.starting_offset = ext_offset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn size(&self) -> Size<Length> {
|
|
||||||
Size {
|
|
||||||
width: self.width,
|
|
||||||
height: self.height,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn layout(
|
|
||||||
&self,
|
|
||||||
_tree: &mut Tree,
|
|
||||||
renderer: &Renderer,
|
|
||||||
limits: &layout::Limits,
|
|
||||||
) -> layout::Node {
|
|
||||||
let image_size = renderer.measure_image(&self.handle);
|
|
||||||
let image_size = Size::new(image_size.width as f32, image_size.height as f32);
|
|
||||||
|
|
||||||
let raw_size = limits.resolve(self.width, self.height, image_size);
|
|
||||||
let full_size = self.content_fit.fit(image_size, raw_size);
|
|
||||||
|
|
||||||
let final_size = Size {
|
|
||||||
width: match self.width {
|
|
||||||
Length::Shrink => f32::min(raw_size.width, full_size.width),
|
|
||||||
_ => raw_size.width,
|
|
||||||
},
|
|
||||||
height: match self.height {
|
|
||||||
Length::Shrink => f32::min(raw_size.height, full_size.height),
|
|
||||||
_ => raw_size.height,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
layout::Node::new(final_size)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_event(
|
|
||||||
&mut self,
|
|
||||||
tree: &mut Tree,
|
|
||||||
event: Event,
|
|
||||||
layout: Layout<'_>,
|
|
||||||
cursor: mouse::Cursor,
|
|
||||||
renderer: &Renderer,
|
|
||||||
_clipboard: &mut dyn Clipboard,
|
|
||||||
shell: &mut Shell<'_, Message>,
|
|
||||||
_viewport: &Rectangle,
|
|
||||||
) -> event::Status {
|
|
||||||
let bounds = layout.bounds();
|
|
||||||
|
|
||||||
match event {
|
|
||||||
Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
|
|
||||||
let Some(cursor_position) = cursor.position_over(bounds) else {
|
|
||||||
return event::Status::Ignored;
|
|
||||||
};
|
|
||||||
|
|
||||||
match delta {
|
|
||||||
mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } => {
|
|
||||||
let state = tree.state.downcast_mut::<State>();
|
|
||||||
let previous_scale = state.scale;
|
|
||||||
|
|
||||||
if y < 0.0 && previous_scale > self.min_scale
|
|
||||||
|| y > 0.0 && previous_scale < self.max_scale
|
|
||||||
{
|
|
||||||
state.scale = (if y > 0.0 {
|
|
||||||
state.scale * (1.0 + self.scale_step)
|
|
||||||
} else {
|
|
||||||
state.scale / (1.0 + self.scale_step)
|
|
||||||
})
|
|
||||||
.clamp(self.min_scale, self.max_scale);
|
|
||||||
|
|
||||||
let scale_factor = state.scale / previous_scale;
|
|
||||||
|
|
||||||
// Cursor position relative to the image center (not bounds center)
|
|
||||||
// The image is centered in bounds, so bounds.center() is correct
|
|
||||||
let cursor_to_center = cursor_position - bounds.center();
|
|
||||||
|
|
||||||
// Transform offset so the point under cursor stays stationary
|
|
||||||
// Formula: new_offset = old_offset * scale_factor + cursor_to_center * (scale_factor - 1)
|
|
||||||
let new_offset = Vector::new(
|
|
||||||
state.current_offset.x * scale_factor
|
|
||||||
+ cursor_to_center.x * (scale_factor - 1.0),
|
|
||||||
state.current_offset.y * scale_factor
|
|
||||||
+ cursor_to_center.y * (scale_factor - 1.0),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clamp offset to valid range
|
|
||||||
let scaled_size = scaled_image_size(
|
|
||||||
renderer,
|
|
||||||
&self.handle,
|
|
||||||
state,
|
|
||||||
bounds.size(),
|
|
||||||
self.content_fit,
|
|
||||||
);
|
|
||||||
|
|
||||||
state.current_offset =
|
|
||||||
clamp_offset(new_offset, bounds.size(), scaled_size);
|
|
||||||
|
|
||||||
// Notify state change
|
|
||||||
if let Some(ref on_change) = self.on_state_change {
|
|
||||||
let image_size = renderer.measure_image(&self.handle);
|
|
||||||
let image_size =
|
|
||||||
Size::new(image_size.width as f32, image_size.height as f32);
|
|
||||||
shell.publish(on_change(
|
|
||||||
state.scale,
|
|
||||||
state.current_offset.x,
|
|
||||||
state.current_offset.y,
|
|
||||||
bounds.size(),
|
|
||||||
image_size,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
event::Status::Captured
|
|
||||||
}
|
|
||||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
|
||||||
if self.disable_pan {
|
|
||||||
return event::Status::Ignored;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(cursor_position) = cursor.position_over(bounds) else {
|
|
||||||
return event::Status::Ignored;
|
|
||||||
};
|
|
||||||
|
|
||||||
let state = tree.state.downcast_mut::<State>();
|
|
||||||
state.cursor_grabbed_at = Some(cursor_position);
|
|
||||||
state.starting_offset = state.current_offset;
|
|
||||||
|
|
||||||
event::Status::Captured
|
|
||||||
}
|
|
||||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
|
||||||
if self.disable_pan {
|
|
||||||
return event::Status::Ignored;
|
|
||||||
}
|
|
||||||
|
|
||||||
let state = tree.state.downcast_mut::<State>();
|
|
||||||
|
|
||||||
if state.cursor_grabbed_at.is_some() {
|
|
||||||
state.cursor_grabbed_at = None;
|
|
||||||
|
|
||||||
// Notify final state after drag ends
|
|
||||||
if let Some(ref on_change) = self.on_state_change {
|
|
||||||
let image_size = renderer.measure_image(&self.handle);
|
|
||||||
let image_size =
|
|
||||||
Size::new(image_size.width as f32, image_size.height as f32);
|
|
||||||
shell.publish(on_change(
|
|
||||||
state.scale,
|
|
||||||
state.current_offset.x,
|
|
||||||
state.current_offset.y,
|
|
||||||
bounds.size(),
|
|
||||||
image_size,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
event::Status::Captured
|
|
||||||
} else {
|
|
||||||
event::Status::Ignored
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
|
||||||
if self.disable_pan {
|
|
||||||
return event::Status::Ignored;
|
|
||||||
}
|
|
||||||
|
|
||||||
let state = tree.state.downcast_mut::<State>();
|
|
||||||
|
|
||||||
if let Some(origin) = state.cursor_grabbed_at {
|
|
||||||
let scaled_size = scaled_image_size(
|
|
||||||
renderer,
|
|
||||||
&self.handle,
|
|
||||||
state,
|
|
||||||
bounds.size(),
|
|
||||||
self.content_fit,
|
|
||||||
);
|
|
||||||
|
|
||||||
let delta = position - origin;
|
|
||||||
|
|
||||||
// Pan: subtract delta from starting offset
|
|
||||||
let new_offset = Vector::new(
|
|
||||||
state.starting_offset.x - delta.x,
|
|
||||||
state.starting_offset.y - delta.y,
|
|
||||||
);
|
|
||||||
|
|
||||||
state.current_offset = clamp_offset(new_offset, bounds.size(), scaled_size);
|
|
||||||
|
|
||||||
// Notify state change during pan
|
|
||||||
if let Some(ref on_change) = self.on_state_change {
|
|
||||||
let image_size = renderer.measure_image(&self.handle);
|
|
||||||
let image_size =
|
|
||||||
Size::new(image_size.width as f32, image_size.height as f32);
|
|
||||||
shell.publish(on_change(
|
|
||||||
state.scale,
|
|
||||||
state.current_offset.x,
|
|
||||||
state.current_offset.y,
|
|
||||||
bounds.size(),
|
|
||||||
image_size,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
event::Status::Captured
|
|
||||||
} else {
|
|
||||||
event::Status::Ignored
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => event::Status::Ignored,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mouse_interaction(
|
|
||||||
&self,
|
|
||||||
tree: &Tree,
|
|
||||||
layout: Layout<'_>,
|
|
||||||
cursor: mouse::Cursor,
|
|
||||||
_viewport: &Rectangle,
|
|
||||||
_renderer: &Renderer,
|
|
||||||
) -> mouse::Interaction {
|
|
||||||
let state = tree.state.downcast_ref::<State>();
|
|
||||||
let bounds = layout.bounds();
|
|
||||||
let is_mouse_over = cursor.is_over(bounds);
|
|
||||||
|
|
||||||
if state.is_cursor_grabbed() {
|
|
||||||
mouse::Interaction::Grabbing
|
|
||||||
} else if is_mouse_over {
|
|
||||||
mouse::Interaction::Grab
|
|
||||||
} else {
|
|
||||||
mouse::Interaction::None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw(
|
|
||||||
&self,
|
|
||||||
tree: &Tree,
|
|
||||||
renderer: &mut Renderer,
|
|
||||||
_theme: &Theme,
|
|
||||||
_style: &renderer::Style,
|
|
||||||
layout: Layout<'_>,
|
|
||||||
_cursor: mouse::Cursor,
|
|
||||||
_viewport: &Rectangle,
|
|
||||||
) {
|
|
||||||
let state = tree.state.downcast_ref::<State>();
|
|
||||||
let bounds = layout.bounds();
|
|
||||||
|
|
||||||
let scaled_size = scaled_image_size(
|
|
||||||
renderer,
|
|
||||||
&self.handle,
|
|
||||||
state,
|
|
||||||
bounds.size(),
|
|
||||||
self.content_fit,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Calculate translation to center the image and apply offset
|
|
||||||
let translation = {
|
|
||||||
// How much space is left after placing the scaled image
|
|
||||||
let diff_w = bounds.width - scaled_size.width;
|
|
||||||
let diff_h = bounds.height - scaled_size.height;
|
|
||||||
|
|
||||||
// Base position: center the image in the viewport
|
|
||||||
// For images smaller than viewport: center them (diff > 0)
|
|
||||||
// For images larger than viewport: they extend beyond bounds (diff < 0)
|
|
||||||
let center_offset = Vector::new(diff_w / 2.0, diff_h / 2.0);
|
|
||||||
|
|
||||||
// Apply pan offset (offset moves the "camera", so subtract it)
|
|
||||||
// Positive offset = looking at right/bottom part = image moves left/up
|
|
||||||
center_offset - state.current_offset
|
|
||||||
};
|
|
||||||
|
|
||||||
let drawing_bounds = Rectangle::new(bounds.position(), scaled_size);
|
|
||||||
|
|
||||||
let render = |renderer: &mut Renderer| {
|
|
||||||
renderer.with_translation(translation, |renderer| {
|
|
||||||
renderer.draw_image(
|
|
||||||
self.handle.clone(),
|
|
||||||
self.filter_method,
|
|
||||||
drawing_bounds,
|
|
||||||
Radians(0.0),
|
|
||||||
1.0,
|
|
||||||
[0.0; 4],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
renderer.with_layer(bounds, render);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The local state of a [`Viewer`].
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub struct State {
|
|
||||||
scale: f32,
|
|
||||||
starting_offset: Vector,
|
|
||||||
current_offset: Vector,
|
|
||||||
cursor_grabbed_at: Option<Point>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for State {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
scale: 1.0,
|
|
||||||
starting_offset: Vector::default(),
|
|
||||||
current_offset: Vector::default(),
|
|
||||||
cursor_grabbed_at: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl State {
|
|
||||||
/// Creates a new [`State`].
|
|
||||||
pub fn new() -> Self {
|
|
||||||
State::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns if the cursor is currently grabbed by the [`Viewer`].
|
|
||||||
pub fn is_cursor_grabbed(&self) -> bool {
|
|
||||||
self.cursor_grabbed_at.is_some()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clamps the offset to keep the image within reasonable bounds.
|
|
||||||
///
|
|
||||||
/// The offset represents how far the viewport's center is displaced from the image's center.
|
|
||||||
/// - offset (0, 0) = image centered
|
|
||||||
/// - positive offset = viewing right/bottom part of image
|
|
||||||
/// - negative offset = viewing left/top part of image
|
|
||||||
fn clamp_offset(offset: Vector, viewport_size: Size, image_size: Size) -> Vector {
|
|
||||||
// Maximum allowed offset in each direction
|
|
||||||
// When image is larger than viewport, allow panning up to image edge
|
|
||||||
// When image is smaller than viewport, no panning needed (clamp to 0)
|
|
||||||
let max_offset_x = ((image_size.width - viewport_size.width) / 2.0).max(0.0);
|
|
||||||
let max_offset_y = ((image_size.height - viewport_size.height) / 2.0).max(0.0);
|
|
||||||
|
|
||||||
Vector::new(
|
|
||||||
offset.x.clamp(-max_offset_x, max_offset_x),
|
|
||||||
offset.y.clamp(-max_offset_y, max_offset_y),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, Message, Theme, Renderer, Handle> From<Viewer<Handle, Message>>
|
|
||||||
for Element<'a, Message, Theme, Renderer>
|
|
||||||
where
|
|
||||||
Renderer: 'a + img_renderer::Renderer<Handle = Handle>,
|
|
||||||
Message: 'a + Clone,
|
|
||||||
Handle: Clone + 'a,
|
|
||||||
{
|
|
||||||
fn from(viewer: Viewer<Handle, Message>) -> Element<'a, Message, Theme, Renderer> {
|
|
||||||
Element::new(viewer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the scaled size of the image given current state.
|
|
||||||
/// Calculate the scaled image size after applying content fit and zoom.
|
|
||||||
///
|
|
||||||
/// This is the canonical implementation used by the viewer widget.
|
|
||||||
/// A simplified version exists in `document::utils::scaled_image_size`.
|
|
||||||
pub fn scaled_image_size<Renderer>(
|
|
||||||
renderer: &Renderer,
|
|
||||||
handle: &<Renderer as img_renderer::Renderer>::Handle,
|
|
||||||
state: &State,
|
|
||||||
bounds: Size,
|
|
||||||
content_fit: ContentFit,
|
|
||||||
) -> Size
|
|
||||||
where
|
|
||||||
Renderer: img_renderer::Renderer,
|
|
||||||
{
|
|
||||||
let Size { width, height } = renderer.measure_image(handle);
|
|
||||||
let image_size = Size::new(width as f32, height as f32);
|
|
||||||
|
|
||||||
let adjusted_fit = match content_fit {
|
|
||||||
ContentFit::None => image_size,
|
|
||||||
_ => content_fit.fit(image_size, bounds),
|
|
||||||
};
|
|
||||||
|
|
||||||
Size::new(
|
|
||||||
adjusted_fit.width * state.scale,
|
|
||||||
adjusted_fit.height * state.scale,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
// src/ui/widgets/mod.rs
|
|
||||||
//
|
|
||||||
// Custom widgets module.
|
|
||||||
|
|
||||||
pub mod crop_model;
|
|
||||||
pub mod crop_overlay;
|
|
||||||
pub mod image_viewer;
|
|
||||||
|
|
||||||
// Re-exports for convenience
|
|
||||||
pub use crop_model::{CropSelection, DragHandle};
|
|
||||||
pub use crop_overlay::crop_overlay;
|
|
||||||
pub use image_viewer::Viewer;
|
|
||||||
174
src/workspace/manager.rs
Normal file
174
src/workspace/manager.rs
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// src/workspace/manager.rs
|
||||||
|
//
|
||||||
|
// Workspace operations: session and item CRUD, reordering, and RON persistence.
|
||||||
|
|
||||||
|
use crate::workspace::model::{Item, Session, Workspace};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::path::Path;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
impl Workspace {
|
||||||
|
/// Serialize the workspace to a RON file.
|
||||||
|
pub fn save(&self, path: impl AsRef<Path>) -> Result<()> {
|
||||||
|
let config = ron::ser::PrettyConfig::default();
|
||||||
|
let data =
|
||||||
|
ron::ser::to_string_pretty(self, config).context("Failed to serialize workspace")?;
|
||||||
|
std::fs::write(&path, data).context("Failed to write workspace file")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialize a workspace from a RON file.
|
||||||
|
pub fn load(path: impl AsRef<Path>) -> Result<Self> {
|
||||||
|
let data = std::fs::read_to_string(&path).context("Failed to read workspace file")?;
|
||||||
|
let workspace: Workspace =
|
||||||
|
ron::from_str(&data).context("Failed to deserialize workspace")?;
|
||||||
|
Ok(workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_session(&mut self, session: Session) {
|
||||||
|
self.sessions.push(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_session(&mut self, session_id: Uuid) -> Result<Session> {
|
||||||
|
let pos = self.session_pos(session_id)?;
|
||||||
|
Ok(self.sessions.remove(pos))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn copy_session(&mut self, session_id: Uuid) -> Result<Uuid> {
|
||||||
|
let pos = self.session_pos(session_id)?;
|
||||||
|
let mut clone = self.sessions[pos].clone();
|
||||||
|
clone.id = Uuid::new_v4();
|
||||||
|
clone.name = format!("{} (copy)", clone.name);
|
||||||
|
for item in &mut clone.items {
|
||||||
|
item.id = Uuid::new_v4();
|
||||||
|
}
|
||||||
|
let new_id = clone.id;
|
||||||
|
self.sessions.push(clone);
|
||||||
|
Ok(new_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_session(&mut self, session_id: Uuid, new_index: usize) -> Result<()> {
|
||||||
|
let pos = self.session_pos(session_id)?;
|
||||||
|
let session = self.sessions.remove(pos);
|
||||||
|
let insert_at = new_index.min(self.sessions.len());
|
||||||
|
self.sessions.insert(insert_at, session);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rename_session(&mut self, session_id: Uuid, new_name: impl Into<String>) -> Result<()> {
|
||||||
|
let session = self.session_mut(session_id)?;
|
||||||
|
session.name = new_name.into();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_item(&mut self, session_id: Uuid, item: Item) -> Result<()> {
|
||||||
|
let session = self.session_mut(session_id)?;
|
||||||
|
session.items.push(item);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_item(&mut self, session_id: Uuid, item_id: Uuid) -> Result<Item> {
|
||||||
|
let session = self.session_mut(session_id)?;
|
||||||
|
let pos = item_pos(&session.items, item_id)?;
|
||||||
|
Ok(session.items.remove(pos))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move an item within the same session or between two sessions.
|
||||||
|
pub fn move_item(
|
||||||
|
&mut self,
|
||||||
|
from_session: Uuid,
|
||||||
|
item_id: Uuid,
|
||||||
|
to_session: Uuid,
|
||||||
|
to_index: usize,
|
||||||
|
) -> Result<()> {
|
||||||
|
let item = self.remove_item(from_session, item_id)?;
|
||||||
|
let target = self.session_mut(to_session)?;
|
||||||
|
let insert_at = to_index.min(target.items.len());
|
||||||
|
target.items.insert(insert_at, item);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy an item within the same session or between two sessions.
|
||||||
|
pub fn copy_item(
|
||||||
|
&mut self,
|
||||||
|
from_session: Uuid,
|
||||||
|
item_id: Uuid,
|
||||||
|
to_session: Uuid,
|
||||||
|
to_index: usize,
|
||||||
|
) -> Result<Uuid> {
|
||||||
|
let source = self.session(from_session)?;
|
||||||
|
let pos = item_pos(&source.items, item_id)?;
|
||||||
|
let mut clone = source.items[pos].clone();
|
||||||
|
clone.id = Uuid::new_v4();
|
||||||
|
clone.name = format!("{} (copy)", clone.name);
|
||||||
|
let new_id = clone.id;
|
||||||
|
|
||||||
|
let target = self.session_mut(to_session)?;
|
||||||
|
let insert_at = to_index.min(target.items.len());
|
||||||
|
target.items.insert(insert_at, clone);
|
||||||
|
Ok(new_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rename_item(
|
||||||
|
&mut self,
|
||||||
|
session_id: Uuid,
|
||||||
|
item_id: Uuid,
|
||||||
|
new_name: impl Into<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let session = self.session_mut(session_id)?;
|
||||||
|
let pos = item_pos(&session.items, item_id)?;
|
||||||
|
session.items[pos].name = new_name.into();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only access to a session by id.
|
||||||
|
pub fn session(&self, id: Uuid) -> Result<&Session> {
|
||||||
|
self.sessions
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.id == id)
|
||||||
|
.context(format!("Session {id} not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only access to a session by name.
|
||||||
|
pub fn session_by_name(&self, name: &str) -> Result<&Session> {
|
||||||
|
self.sessions
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name == name)
|
||||||
|
.context(format!("Session {name:?} not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find an item by name within a session.
|
||||||
|
pub fn item_by_name(&self, session_id: Uuid, name: &str) -> Result<&Item> {
|
||||||
|
let session = self.session(session_id)?;
|
||||||
|
session
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.find(|i| i.name == name)
|
||||||
|
.context(format!(
|
||||||
|
"Item {name:?} not found in session {:?}",
|
||||||
|
session.name
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_mut(&mut self, id: Uuid) -> Result<&mut Session> {
|
||||||
|
self.sessions
|
||||||
|
.iter_mut()
|
||||||
|
.find(|s| s.id == id)
|
||||||
|
.context(format!("Session {id} not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_pos(&self, id: Uuid) -> Result<usize> {
|
||||||
|
self.sessions
|
||||||
|
.iter()
|
||||||
|
.position(|s| s.id == id)
|
||||||
|
.context(format!("Session {id} not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn item_pos(items: &[Item], id: Uuid) -> Result<usize> {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.position(|i| i.id == id)
|
||||||
|
.context(format!("Item {id} not found"))
|
||||||
|
}
|
||||||
7
src/workspace/mod.rs
Normal file
7
src/workspace/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// src/workspace/mod.rs
|
||||||
|
//
|
||||||
|
// Workspace subsystem: data model, operations, and persistence.
|
||||||
|
|
||||||
|
pub mod manager;
|
||||||
|
pub mod model;
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue