Compare commits

..

No commits in common. "local/yoda-noctua-pdf-navigation" and "main" have entirely different histories.

102 changed files with 17008 additions and 2620 deletions

2
.gitignore vendored
View file

@ -1,7 +1,7 @@
.cargo/
Cargo.lock
*.pdb
**/*.rs.bk
tmp/
debug/
target/
vendor/

7880
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,38 +1,44 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Cargo.toml
#
# Noctua COSMIC desktop application
# Project metadata and dependencies for the Noctua document viewer.
[package]
name = "noctua"
version = "0.1.0"
edition = "2021"
description = "Noctua a document viewer for the COSMIC™ desktop"
edition = "2024"
description = "A wise document and image viewer for the COSMIC™ desktop"
repository = "https://codeberg.org/wfx/noctua"
authors = ["Wolfgang Morawetz <wfx@mailbox.org>"]
license = "GPL-3.0-or-later"
keywords = ["document", "image", "viewer", "pdf", "cosmic"]
categories = ["gui", "multimedia::graphics", "multimedia::images"]
[features]
default = ["image", "vector", "portable"]
image = ["dep:image", "dep:kamadak-exif"]
vector = ["dep:resvg"]
portable = ["dep:pdfium-render"]
portable = ["dep:poppler", "dep:cairo-rs"]
full = ["image", "vector", "portable"]
[dependencies]
# Serialization
serde = { version = "1", features = ["derive"] }
ron = "0.8"
# Error handling
anyhow = "1"
# Feature-gated document backends
image = { version = "0.25", optional = true }
kamadak-exif = { version = "0.5", optional = true }
pdfium-render = { version = "0.8", optional = true }
# Feature-gated dependencies
kamadak-exif = { version = "0.5.5", optional = true }
image = { version = "0.25.9", optional = true }
poppler = { version = "0.4", features = ["render"], optional = true }
cairo-rs = { version = "0.18", features = ["png"], optional = true }
resvg = { version = "0.45", optional = true }
# Async / concurrency
futures-util = "0.3.31"
tokio = { version = "1.48.0", features = ["full"] }
# Logging
log = "0.4"
env_logger = "0.11"
log = "0.4.20"
simple_logger = "4.2.0"
# i18n / localization
i18n-embed = { version = "0.16", features = [
@ -41,35 +47,41 @@ i18n-embed = { version = "0.16", features = [
] }
i18n-embed-fl = "0.10"
# Resource embedding
rust-embed = "8"
# Open files/URLs with the system default application
open = "5"
# Thumbnail cache (freedesktop.org spec)
# Misc utilities
open = "5.3.2"
rust-embed = "8.8.0"
dirs = "5.0"
md5 = "0.7"
png = "0.17"
sha2 = "0.10"
clap = { version = "4.5.54", features = ["derive"] }
env_logger = "0.11.8"
wallpaper = "3.2"
# 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
[dependencies.libcosmic]
git = "https://github.com/pop-os/libcosmic.git"
# See https://github.com/pop-os/libcosmic/blob/master/Cargo.toml for available features.
features = [
# Accessibility support
"a11y",
# About widget for the app
"about",
# Uses cosmic-settings-daemon to watch for config file changes
"dbus-config",
# Support creating additional application windows.
"multi-window",
# On app startup, focuses an existing instance if the app is already open
"single-instance",
# Uses tokio as the executor for the runtime
"tokio",
# Windowing support for X11, Windows, Mac, & Redox
"winit",
# Add Wayland support to winit
"wayland",
# GPU-accelerated rendering
"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" }

2
README.md Normal file
View file

@ -0,0 +1,2 @@
# noctua

202
docs/features.md Normal file
View file

@ -0,0 +1,202 @@
# 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

BIN
docs/images/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

193
docs/usage.md Normal file
View file

@ -0,0 +1,193 @@
# 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.

121
i18n/cs/noctua.ftl Normal file
View file

@ -0,0 +1,121 @@
# 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 }…

View file

@ -33,6 +33,20 @@ menu-view-rotate-cw = Rotate 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
status-zoom-fit = Fit
status-zoom-percent = { $percent }%
@ -43,11 +57,6 @@ status-separator = |
## Placeholders / Empty states
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

127
i18n/sv/noctua.ftl Normal file
View file

@ -0,0 +1,127 @@
# 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

View file

@ -1,4 +1,4 @@
# Name of the application binary (produced by the ui crate).
# Name of the application's binary.
name := 'noctua'
# The unique ID of the application.
appid := 'org.codeberg.wfx.Noctua'
@ -25,7 +25,7 @@ desktop-dst := base-dir / 'share' / 'applications' / desktop
icons-dst := base-dir / 'share' / 'icons' / 'hicolor'
icon-svg-dst := icons-dst / 'scalable' / 'apps' / icon-svg
# Default recipe
# Default recipe which runs `just build-release`
default: build-release
# Runs `cargo clean`
@ -39,9 +39,9 @@ clean-vendor:
# `cargo clean` and removes vendored dependencies
clean-dist: clean clean-vendor
# Compiles the entire workspace with debug profile
# Compiles with debug profile
build-debug *args:
cargo build --workspace {{args}}
cargo build {{args}}
# Compiles with release profile
build-release *args: (build-debug '--release' args)
@ -49,18 +49,14 @@ build-release *args: (build-debug '--release' args)
# Compiles release profile with vendored dependencies
build-vendored *args: vendor-extract (build-release '--frozen --offline' args)
# Runs clippy on the entire workspace
# Runs a clippy check
check *args:
cargo clippy --workspace --all-features {{args}} -- -W clippy::pedantic
cargo clippy --all-features {{args}} -- -W clippy::pedantic
# Runs clippy with JSON message format
# Runs a clippy check with JSON message format
check-json: (check '--message-format=json')
# Runs tests on the entire workspace
test *args:
cargo test --workspace {{args}}
# Runs the application (no args = COSMIC UI, with args = CLI mode)
# Run the application for testing purposes
run *args:
env RUST_BACKTRACE=full cargo run --release {{args}}
@ -95,10 +91,10 @@ vendor-extract:
rm -rf vendor
tar pxf vendor.tar
# Bump version in all crates, commit and tag
# Bump cargo version, create git commit, and create tag
tag version:
find -type f -name Cargo.toml -exec sed -i '0,/^version/s/^version.*/version = "{{version}}"/' '{}' \; -exec git add '{}' \;
cargo check --workspace
cargo check
cargo clean
git add Cargo.lock
git commit -m 'release: {{version}}'

View file

@ -1,12 +1,14 @@
[Desktop Entry]
Name=Noctua
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
Icon=org.codeberg.wfx.Noctua
Exec=/usr/bin/noctua %F
Exec=noctua %F
Terminal=false
StartupNotify=true
Categories=Graphics;Viewer;Utility;
StartupWMClass=org.codeberg.wfx.Noctua
Keywords=image;document;pdf;viewer;cosmic;
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;
Keywords[cs]=obrázek;dokument;pdf;prohlížeč;fotka;
MimeType=image/png;image/jpeg;image/gif;image/webp;image/bmp;image/tiff;image/svg+xml;application/pdf;

View file

@ -8,12 +8,17 @@
<project_license>GPL-3.0-or-later</project_license>
<name>Noctua</name>
<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>
<p>
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.
</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>
<launchable type="desktop-id">org.codeberg.wfx.Noctua.desktop</launchable>
@ -25,14 +30,10 @@
<provides>
<binary>noctua</binary>
<mediatype>image/png</mediatype>
<mediatype>image/x-png</mediatype>
<mediatype>image/jpeg</mediatype>
<mediatype>image/jpg</mediatype>
<mediatype>image/pjpeg</mediatype>
<mediatype>image/gif</mediatype>
<mediatype>image/webp</mediatype>
<mediatype>image/bmp</mediatype>
<mediatype>image/x-bmp</mediatype>
<mediatype>image/tiff</mediatype>
<mediatype>image/svg+xml</mediatype>
<mediatype>application/pdf</mediatype>

5
rustfmt.toml Normal file
View file

@ -0,0 +1,5 @@
# rustfmt.toml
reorder_imports = false
reorder_modules = false
imports_granularity = "Preserve"
group_imports = "Preserve"

View file

@ -1,165 +0,0 @@
// 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())
}
}

View file

@ -0,0 +1,228 @@
// 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);
}
}

View file

@ -0,0 +1,10 @@
// 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;

View file

@ -0,0 +1,67 @@
// 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);
}
}

View file

@ -0,0 +1,34 @@
// 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()
}
}

View file

@ -0,0 +1,64 @@
// 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()
}
}

View file

@ -0,0 +1,81 @@
// 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);
}
}

View file

@ -0,0 +1,216 @@
// 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()
}
}

11
src/application/mod.rs Normal file
View file

@ -0,0 +1,11 @@
// 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;

View file

@ -0,0 +1,81 @@
// 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
}
}

View file

@ -0,0 +1,7 @@
// 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;

View file

@ -0,0 +1,119 @@
// 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);
}
}

44
src/config.rs Normal file
View file

@ -0,0 +1,44 @@
// 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,
}
}
}

View file

@ -1,13 +0,0 @@
// 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, …
}

View file

@ -1,101 +0,0 @@
// 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();
}
}

View file

@ -1,418 +0,0 @@
// 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);
}
}

View file

@ -1,24 +0,0 @@
// 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),
}

View file

@ -1,12 +0,0 @@
// 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;

View file

@ -1,14 +0,0 @@
// 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,
}

View file

@ -1,12 +0,0 @@
// 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),
}

View file

@ -1,13 +0,0 @@
// 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>,
}

View file

@ -1,66 +0,0 @@
// 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 },
}

View file

@ -1,72 +0,0 @@
// 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,
}
}
}

View file

@ -1,8 +0,0 @@
// 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;

View file

@ -1,62 +0,0 @@
// 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)
}

View file

@ -1,17 +0,0 @@
// 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,
}

View file

@ -1,45 +0,0 @@
// 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,
}
}
}

View file

@ -1,11 +0,0 @@
// 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;

View file

@ -1,16 +0,0 @@
// 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,
}

View file

@ -0,0 +1,277 @@
// 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
}
}

View file

@ -0,0 +1,403 @@
// 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),
}
}
}

View file

@ -0,0 +1,250 @@
// 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<()>;
}

View file

@ -0,0 +1,197 @@
// 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>,
}

View file

@ -0,0 +1,13 @@
// 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;

View file

@ -0,0 +1,74 @@
// 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)
}
}

View file

@ -0,0 +1,17 @@
// 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.

View file

@ -0,0 +1,281 @@
# 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

View file

@ -0,0 +1,31 @@
// 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
}
}

View file

@ -0,0 +1,161 @@
// 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);
}
}

View file

@ -0,0 +1,16 @@
// 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.

View file

@ -0,0 +1,108 @@
// 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);
}
}

View file

@ -0,0 +1,323 @@
// 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)
);
}
}

View file

@ -0,0 +1,10 @@
// 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;

View file

@ -0,0 +1,473 @@
// 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()))
}
}

View file

@ -0,0 +1,353 @@
// 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;
}
}

View file

@ -0,0 +1,334 @@
// 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)
}

20
src/domain/mod.rs Normal file
View file

@ -0,0 +1,20 @@
// 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.

View file

@ -1,46 +0,0 @@
// 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 Normal file
View file

@ -0,0 +1,55 @@
// 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 Normal file
View file

@ -0,0 +1,9 @@
// 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;

View file

@ -0,0 +1,149 @@
// 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}")))
}
}

View file

@ -0,0 +1,189 @@
// 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())
}
*/

View file

@ -0,0 +1,9 @@
// 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};

View file

@ -0,0 +1,148 @@
// 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")));
}
}

View file

@ -0,0 +1,15 @@
// 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;

View file

@ -0,0 +1,50 @@
// 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")));
}
}

View file

@ -0,0 +1,46 @@
// 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")));
}
}

View file

@ -0,0 +1,49 @@
// 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")));
}
}

13
src/infrastructure/mod.rs Normal file
View file

@ -0,0 +1,13 @@
// 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;

View file

@ -0,0 +1,9 @@
// 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;

View file

@ -0,0 +1,159 @@
// 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
}
}

View file

@ -1,734 +1,43 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/main.rs
//
// Minimal COSMIC viewer entry point for images and PDFs.
// Application entry point.
mod ui;
mod application;
mod domain;
mod infrastructure;
mod config;
mod i18n;
use anyhow::Result;
use clap::Parser;
use cosmic::app::{Core, Settings, Task};
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;
use cosmic::app::Settings;
use crate::ui::NoctuaApp;
#[derive(Parser, Debug)]
#[command(
name = "noctua",
version,
about = "A document and image viewer for COSMIC"
)]
struct Args {
/// Files to open.
#[derive(Parser, Debug, Clone)]
#[command(version, about)]
pub struct Args {
/// File to open on startup
#[arg(value_name = "FILE")]
files: Vec<PathBuf>,
pub file: Option<std::path::PathBuf>,
/// Compatibility with the previous CLI harness.
#[arg(long = "open-document", value_name = "PATH")]
document: Option<PathBuf>,
/// Open all supported documents in a directory.
#[arg(long = "open-directory", value_name = "PATH")]
directory: Option<PathBuf>,
/// UI language (e.g. "en", "de")
#[arg(short, long, default_value = "en")]
pub language: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum DocumentKind {
Raster,
Svg,
Pdf,
}
fn main() -> Result<()> {
// Get the system's preferred languages.
let requested_languages = i18n_embed::DesktopLanguageRequester::requested_languages();
#[derive(Clone, Debug)]
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();
// Enable localizations to be applied.
i18n::init(&requested_languages);
env_logger::init();
let args = Args::parse();
let flags = Flags::from_args(args);
let settings = Settings::default().size_limits(Limits::NONE.min_width(480.0).min_height(320.0));
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()
cosmic::app::run::<NoctuaApp>(Settings::default(), ui::app::Flags::Args(args))
.map_err(|e| anyhow::anyhow!(e))
}

View file

@ -1,338 +0,0 @@
// 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:?}"),
}
}

View file

@ -1,4 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/render/converter.rs
//
// Converts raw document data into renderable page output.

View file

@ -1,7 +0,0 @@
// 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;

View file

@ -1,4 +0,0 @@
// 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 Normal file
View file

@ -0,0 +1,403 @@
// 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()
}
}

6
src/ui/components/mod.rs Normal file
View file

@ -0,0 +1,6 @@
// 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

102
src/ui/message.rs Normal file
View file

@ -0,0 +1,102 @@
// 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,
}

17
src/ui/mod.rs Normal file
View file

@ -0,0 +1,17 @@
// 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 Normal file
View file

@ -0,0 +1,280 @@
// 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 Normal file
View file

@ -0,0 +1,404 @@
// 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());
}

79
src/ui/views/canvas.rs Normal file
View file

@ -0,0 +1,79 @@
// 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()
}
}

87
src/ui/views/footer.rs Normal file
View file

@ -0,0 +1,87 @@
// 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()
}

View file

@ -0,0 +1,137 @@
// 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()
}

91
src/ui/views/header.rs Normal file
View file

@ -0,0 +1,91 @@
// 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(),
]
}

180
src/ui/views/meta_panel.rs Normal file
View file

@ -0,0 +1,180 @@
// 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()
}

51
src/ui/views/mod.rs Normal file
View file

@ -0,0 +1,51 @@
// 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()
}),
}
}

103
src/ui/views/pages_panel.rs Normal file
View file

@ -0,0 +1,103 @@
// 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(),
)
}

43
src/ui/views/panels.rs Normal file
View file

@ -0,0 +1,43 @@
// 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()
}

View file

@ -0,0 +1,210 @@
// 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
}
})
}
}

View file

@ -0,0 +1,404 @@
// 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()
}

View file

@ -0,0 +1,559 @@
// 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,
)
}

13
src/ui/widgets/mod.rs Normal file
View file

@ -0,0 +1,13 @@
// 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;

View file

@ -1,174 +0,0 @@
// 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"))
}

View file

@ -1,7 +0,0 @@
// 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