Compare commits

...

79 commits

Author SHA1 Message Date
a8d34b8118 Merge forgejo: keep local README 2026-05-21 20:03:22 +02:00
496614f790 feat(pdf): improve PDF rendering quality and zoom sharpness
- Change PDF_RENDER_QUALITY from 2.0 to 3.0 for higher resolution rendering
- Replace PNG round-trip with direct Cairo surface → DynamicImage conversion
- Convert ARgb32 to RGBA directly, avoiding PNG encoding/decoding artifacts
- Switch image filter from Nearest to Linear for smoother zoom display
- Remove unused Cursor and ImageReader imports
- Strip release binary to reduce size from 612MB to 36MB
2026-05-21 19:59:07 +02:00
Wolfgang Morawetz
fc6e8c8056
Merge pull request #2 from bittin/main
Create Swedish localization for Noctua by bittin
thx a lot.
2026-02-24 17:45:57 +01:00
Wolfgang Morawetz
ee51827262
Merge pull request #1 from lorduskordus/czech-translation
i18n(cs): Add Czech translation by lorduskordus
thx a lot.
2026-02-24 17:43:23 +01:00
lorduskordus
3be3203fe4
i18n(cs): Add Czech translation 2026-02-24 16:50:48 +01:00
Luna Jernberg
3292cd4cf5
Create Swedish localization for Noctua
Added localization strings for Noctua in Swedish.
2026-02-19 08:17:30 +01:00
mow
6bd5426b51 chore: update README and screenshot 2026-02-05 18:03:59 +01:00
mow
7c7cf41183 chore: remove development artifacts from repository 2026-02-05 17:51:07 +01:00
mow
44ed10ba42 chore: remove development artifacts from repository 2026-02-05 17:49:29 +01:00
mow
ea4acf9fdd Auto-open nav bar with thumbnails for multi-page documents
- Automatically show nav bar and thumbnail panel when loading PDF files
- Hide nav bar when switching to single-page documents
- Apply auto-toggle on initial load and document navigation
- Thumbnails are already generated via DocumentManager.open_document()
2026-02-05 17:30:14 +01:00
mow
a8344f77a1 Fix initial document display and set Fit mode as default
- Cache rendered image handle on startup to fix 'No document loaded' issue
- Set ViewMode::Fit as default for initial load and navigation
- Disable tooltips in header (too ugly)
- Temporarily disable main menu button (needs proper implementation)
2026-02-05 17:27:44 +01:00
wfx
9624eec699 chore: ignore for .zed 2026-02-05 14:59:43 +01:00
wfx
6748a25e19 chore: remove .zed/settings.json from repository 2026-02-05 14:54:00 +01:00
wfx
6002b37406 feat: UI rewrite - TEA architecture, eliminate sync pattern
Complete UI layer refactoring to achieve Clean Architecture + TEA principles.

Major Changes:
- Eliminated sync.rs (76 LOC of manual synchronization)
- Restructured AppModel: pure UI state only
  - Introduced AppMode enum (View, Crop, Transform, Fullscreen)
  - Added Viewport struct (scale, pan, canvas, cached_image_handle)
  - Added PanelState struct (left, right panels)
- Removed all cached document data from UI layer
- Views now access DocumentManager directly (no caching)
- Update functions work on both model and manager directly

Architecture:
- TEA-compliant: Single source of truth, unidirectional flow
- Clean separation: UI state vs Document state
- No manual synchronization required

Benefits:
- Simplified codebase: -1,986 LOC net (-75.6%)
- Better maintainability: Clear responsibilities
- Type-safe state: Enums instead of flags
- Performance: Cached rendering where needed

Refactored Files:
- src/ui/model.rs: Complete rewrite
- src/ui/update.rs: Complete rewrite
- src/ui/views/*: Updated to use new architecture
- src/ui/views/meta_panel.rs: Extracted from panels.rs

Testing:
- All 24 unit tests passing
- Compiles successfully (cargo check, cargo build)
- 32 warnings (non-critical, future features)

BREAKING CHANGES: None (internal refactoring only)

Co-authored-by: Clean Architecture principles
Co-authored-by: TEA (The Elm Architecture) pattern
2026-02-05 10:41:51 +01:00
wfx
5126b24cb2 fix: Add bounds offset when drawing crop overlay
The fix: cursor.position_in(bounds) returns RELATIVE coordinates,
but we were drawing at those relative positions directly.

Solution: Add bounds.x and bounds.y offset when drawing.
- selection.region stores relative coords (from cursor)
- when drawing: absolute = relative + bounds offset

This fixes the Y-position being too far up.
2026-02-04 18:32:10 +01:00
wfx
d05c459e92 fix: Simplify crop_overlay - NO coordinate transformation
KISS principle: Widget is in stack over canvas, uses SAME coordinates!
- Mouse position = exact position to draw
- selection.region = canvas pixel coordinates
- NO relative/absolute conversion needed
- NO bounds offset calculations

Removed all the over-engineered coordinate math.
2026-02-04 18:25:39 +01:00
wfx
22bf19ddd3 refactor: Remove redundant keyboard handlers from crop_overlay widget
Keyboard events (Enter/Escape) are already handled globally in app.rs
subscription and work correctly with Crop mode detection.

Widget-level keyboard events don't work without focus anyway.
Only mouse events (click, drag, double-click) stay in the widget.
2026-02-04 18:19:44 +01:00
wfx
32468c2758 fix: Crop overlay coordinate system and add Enter/Double-click support
B1: Fixed handle positions - now uses relative coordinates correctly
    - Handles are drawn at correct positions (relative to bounds)
    - Conversion between screen and relative coords

B2: Added Enter and Double-click for Apply
    - Enter key applies crop when selection exists
    - Escape key cancels crop
    - Double-click on selection center applies crop (400ms window)
2026-02-04 18:16:48 +01:00
wfx
531cfef715 refactor: Clean Architecture - Separate domain (CropRegion) from UI (crop_model)
- CropRegion moved to domain/document/operations/crop.rs (pure model)
- crop_types.rs renamed to crop_model.rs (honest UI model name)
- DragHandle and CropSelection stay in UI layer (where they belong)
- Clean separation: Domain has no UI concerns, UI imports from Domain
2026-02-04 16:46:41 +01:00
wfx
be956f701b feat: Implement clean crop overlay (395 lines, simple stack-based approach) 2026-02-04 16:36:35 +01:00
wfx
f60907b51c refactor: Move image_viewer to widgets/ (it's a widget, not a view) 2026-02-04 16:33:52 +01:00
wfx
a9284bc22b REVERT: Back to working viewer - Keep what works (Mouse Zoom/Pan), Remove over-engineering 2026-02-04 16:29:09 +01:00
wfx
49bff3dd89 Step 4 complete: Add CropWidget (444 lines) and crop dialog - Crop feature restored! 2026-02-04 16:20:08 +01:00
wfx
5d729c7495 Step 3 complete: Replace custom image_viewer (559 lines) with standard widgets (~120 lines) 2026-02-04 16:10:14 +01:00
wfx
387afdf4f2 Step 3 prep: Domain renders at 1.0, add scroll_id to model 2026-02-04 16:05:58 +01:00
wfx
35ff783f25 Step 2: Remove old complex crop components (~500 lines deleted) 2026-02-04 16:00:53 +01:00
wfx
34d0045e0d Step 1: Add simplified crop types in ui/widgets, update imports 2026-02-04 16:00:26 +01:00
wfx
6a4629bb47 Before UI simplification - Clean Architecture complete 2026-02-04 15:56:44 +01:00
wfx
fc73e4b76b Complete Clean Architecture migration
Phase 1-7: Full migration from src/app/ to Clean Architecture

BREAKING CHANGES:
- Removed src/app/ (old TEA-style implementation)
- Removed src/constant.rs (constants now local to modules)
- Removed deprecated canvas_to_image_coords functions

NEW STRUCTURE:
- src/ui/           - UI Layer (COSMIC interface)
- src/application/  - Application Layer (DocumentManager, Commands)
- src/domain/       - Domain Layer (Document types, Operations)
- src/infrastructure/ - Infrastructure Layer (Loaders, Cache, System)

FEATURES:
- DocumentManager as Single Source of Truth
- Command Pattern for all operations
- Model caching for render data (performance)
- Sync mechanism between DocumentManager and UI Model
- Wallpaper support (COSMIC, KDE, GNOME, feh)
- Thumbnail cache with disk persistence

IMPROVEMENTS:
- Warnings: 62 → 43 (-31%)
- Deprecated warnings: 2 → 0 (-100%)
- Code removed: src/app/ (~2000 lines), constant.rs, deprecated functions
- Better Locality of Reference (constants local to modules)
- Clean separation of concerns
- No circular dependencies

DOCUMENTATION:
- Updated AGENTS.md (100% migration status)
- Updated README.md (architecture section)
- Updated Workflow.md
- Added Migration-Plan.md with full completion summary

TESTS:
- All 41 tests passing
- Build successful (0 errors, 43 warnings)
- Release build verified

Migration Status: ✅ 100% Complete
2026-02-03 08:43:21 +01:00
wfx
f8087a3c6a docs: Traits & Enums comparison - Domain is complete
- All app/ traits present in domain/
- Domain has additional features (fine rotation, interpolation)
- RotationMode is more flexible than plain Rotation
- Better error handling in MultiPageThumbnails
- No changes needed - domain version is superior

Refs: Migration Step 1.7
2026-02-02 22:00:37 +01:00
wfx
beab03ccf7 docs: Metadata comparison - no changes needed
- Verified BasicMeta, ExifMeta, DocumentMeta are identical
- Domain version is better organized (extract_meta in types)
- from_bytes() is public in domain (vs private in app)
- No consolidation needed

Refs: Migration Step 1.6
2026-02-02 21:59:01 +01:00
wfx
252ccdd95b feat(domain): Add crop() and extract_meta() to DocumentContent
- crop(): Universal crop for all document types (delegates to type impl)
- extract_meta(): Extract metadata from any document type
- Both methods work via type erasure pattern

Refs: Migration Step 1.5
2026-02-02 21:51:15 +01:00
wfx
c8545627fa feat(domain): Add extract_meta() and crop() to PortableDocument
- extract_meta(): Extract PDF metadata (page count, dimensions)
- crop(): Render-based crop on current page
- Completes PortableDocument feature parity with app/

Refs: Migration Step 1.4
2026-02-02 21:48:54 +01:00
wfx
5c830d5504 feat(domain): Add extract_meta() and crop() to VectorDocument
- extract_meta(): Extract basic metadata (SVG format, native dimensions)
- crop(): Render-based crop on rasterized output
- Completes VectorDocument feature parity with app/

Refs: Migration Step 1.3
2026-02-02 21:46:37 +01:00
wfx
8ff43ea5d7 feat(domain): Add crop_to_image() and extract_meta() to RasterDocument
- crop_to_image(): Non-destructive crop returning DynamicImage
- extract_meta(): Extract BasicMeta and EXIF metadata
- Completes migration of RasterDocument features from app/ to domain/

Refs: Migration Step 1.2
2026-02-02 21:45:11 +01:00
wfx
3cf99ad19d feat: implement crop functionality with self-contained widget
- Inspired by cosmic-viewer's crop implementation (https://codeberg.org/bhh32/cosmic-viewer)
- Add crop support for all document types (Raster, Vector, Portable)
2026-01-22 20:40:36 +01:00
wfx
9399a008c4 refactor: improve code quality and consistency
Comprehensive code audit addressing naming consistency, dead code removal,
and documentation improvements across the entire codebase.

- Rename panel_pages.rs → pages_panel.rs for consistent naming
- Unify view functions: all panels now use view() convention
- Rename header_start/end() → start/end() for brevity
- Update all imports and references

- Remove Rotation::from_degrees() (only 4 fixed states needed)
- Remove TransformState::identity() and is_identity() (Default suffices)
- Remove duplicate convenience methods from RasterDocument, VectorDocument,
  PortableDocument (rotate_cw/ccw, flip_horizontal/vertical)
- Remove unused imports and constants (ROTATION_STEP, FULL_ROTATION)

- Rename constants: PDF_RENDER_SCALE → PDF_RENDER_QUALITY,
  PDF_THUMBNAIL_SCALE → PDF_THUMBNAIL_SIZE
- Add comprehensive trait documentation explaining type erasure pattern
- Document why large enum variants are acceptable
- Add #[allow(dead_code)] with explanations for trait API types
- Improve all constant and config comments

- Collapse nested if statements using Rust 2024 let-chains
- Replace single-arm match with if-let
- Introduce StateChangeCallback<Message> type alias
- Apply clippy auto-fixes for better code style
2026-01-19 19:42:54 +01:00
wfx
0e31b146a3 docs: update 2026-01-18 20:42:46 +01:00
wfx
1182b7b55d feature: PDF and PDF thumbnails and refresh UI
- Implement PDF and PDF thumbnail generation with incremental loading
- Add UI refresh mechanism (tick counter + RefreshView message)
- Improve fl! macro with named parameters
- Clean up code organization (mod.rs: wiring, model.rs: state only)
2026-01-18 20:35:12 +01:00
wfx
220a886acc feat: add set as wallpaper functionality
Add ability to set current image as desktop wallpaper with keyboard
shortcut 'W' and icon button in Properties panel.

Supports COSMIC, GNOME, KDE, XFCE, and tiling window managers via
automatic detection and fallback mechanism.

Implementation uses wallpaper crate with custom COSMIC config file
integration and gsettings/feh fallbacks.
2026-01-15 20:40:53 +01:00
mow
ca7661fa3e docs: rename link from 'Keyboard Shortcuts' to 'Usage'
The usage.md now contains comprehensive user guide beyond
just keyboard shortcuts.
2026-01-15 19:06:32 +01:00
mow
a4bba8d4f1 chore: remove unused panel icon resources
Remove panel-left-symbolic.svg and panel-right-symbolic.svg
as they are no longer needed.
2026-01-15 19:04:58 +01:00
mow
31fd194d44 docs: update configuration path to correct COSMIC location 2026-01-15 18:52:03 +01:00
mow
4cfb56de61 docs: clarify zoom in/out mathematics
Explain that zoom out is the mathematical inverse of zoom in
(divide by 1.1 instead of multiply), making the operations
reversible.
2026-01-15 18:44:29 +01:00
mow
3726ea16de docs: update usage guide to current implementation status
- Mark all zoom and pan controls as fully working
- Remove status column (all shortcuts are implemented)
- Add detailed descriptions for each feature
- Include mouse controls and toolbar usage
- Add tips for keyboard-driven workflow
- Document bidirectional control synchronization
- Remove or mark planned features accurately
2026-01-15 18:42:30 +01:00
mow
47f2c7020c docs: add (Implemented) markers to all current features
Ensures consistent labeling throughout the Current Features section.
2026-01-15 18:39:56 +01:00
mow
6115e25fed docs: add (Implemented) markers consistently
Mark all current features with (Implemented) for consistency
with the Raster Images section.
2026-01-15 18:37:44 +01:00
mow
3ca9315e7f docs: remove reference to non-existent CONTRIBUTING.md 2026-01-15 18:34:31 +01:00
mow
b7d024bde2 docs: correct feature status for SVG and PDF
Both SVG and PDF support are prepared in code structure but
not yet functionally implemented. They currently show 1x1
placeholder images. Moved to 'Planned Features' section with
proper status documentation.
2026-01-15 18:32:18 +01:00
mow
7df18617bd docs: remove icons from feature documentation
Replace visual icons with text labels (Implemented, Planned, etc.)
for better readability and maintainability.
2026-01-15 18:30:20 +01:00
mow
ffcca4dbfe docs: add comprehensive feature documentation
Document all implemented and planned features:
- Current features with implementation status
- Planned features organized by priority
- Technical architecture details
- Keyboard shortcuts reference
- Contributing guidelines for new features
2026-01-15 18:28:56 +01:00
mow
00425fcf4b chore: suppress dead_code warnings for future features
Mark unused message variants with #[allow(dead_code)] to indicate
they are intentionally kept for future implementation:
- OpenPath: for file dialog integration
- RefreshMetadata: for metadata panel updates
- ShowError/ClearError: for error handling UI
- NoOp: fallback/placeholder message
2026-01-15 18:21:50 +01:00
mow
c132265d31 refactor: use zoom_factor() method in footer
Simplifies the zoom text calculation and eliminates the
'method zoom_factor is never used' warning.
2026-01-15 18:20:14 +01:00
mow
4593986fc6 refactor: remove unused config field from AppModel
The config is only needed in Noctua struct, not in AppModel.
This eliminates the 'field config is never read' warning.
2026-01-15 18:19:09 +01:00
mow
68b21215f4 chore: fix compiler warnings
- Add explicit lifetimes to Element return types
- Remove unused imports (Length, button, horizontal_space, icon, Handle, self)
- Mark unused parameter with underscore prefix

This eliminates all 'hiding a lifetime' and 'unused import' warnings.
2026-01-15 18:17:09 +01:00
mow
69f22bafcd feat(viewer): implement keyboard and button zoom/pan controls
- Fork cosmic::iced viewer widget to enable external state control
- Add bidirectional state synchronization between viewer and AppModel
- Implement ViewerStateChanged message for mouse interaction feedback
- Fix pixel-perfect rendering at 100% zoom (ActualSize mode)
- Ensure smooth interaction between mouse, keyboard, and button controls

This allows zoom/pan to work via:
- Keyboard shortcuts (+/-, Ctrl+arrows)
- Future toolbar buttons
- Mouse wheel and drag (existing functionality preserved)

The viewer state now properly syncs in both directions:
- External controls (keyboard/buttons) → update viewer state via diff()
- Mouse interactions → update AppModel via ViewerStateChanged message

Closes: Zoom/pan via keyboard and buttons now functional
2026-01-15 18:10:57 +01:00
wfx
2905a3f6f1 docs: moved from docs/images to docs/ 2026-01-15 06:24:28 +01:00
wfx
4afc049f60 docs: reorganize documentation
- Simplify README
- Add docs/features.md (formats, roadmap)
- Add docs/usage.md (shortcuts)
- Keep docs/development.md (build instructions)
2026-01-15 06:20:00 +01:00
wfx
e71a97a2cb fix(config): use XDG picture directory as default
Replace hardcoded \"~/Pictures\" with dirs::picture_dir() which resolves
to the actual absolute path (e.g. /home/user/Pictures).

The tilde (~) in paths is not automatically expanded by Rust's PathBuf,
causing path.exists() to return false for \"~/Pictures\".

Using dirs::picture_dir() provides:
- Proper absolute path resolution
- XDG Base Directory compliance
- Fallback to home directory if Pictures doesn't exist

Note: Users with existing config files need to delete
~/.config/cosmic/org.codeberg.wfx.Noctua/v1/default_image_dir
to apply the new default."
2026-01-14 21:03:06 +01:00
mow
afdee6b430 docs: update screenshot 2026-01-14 19:01:58 +01:00
mow
7b36ff143c feat(ui): add header toolbar with navigation and transform buttons
- Add header bar with nav toggle, prev/next, rotate and flip buttons
- Extract header rendering to view/header.rs (MVU architecture)
- Add RotateCW, RotateCCW, FlipHorizontal, FlipVertical messages
- Add PrevDocument, NextDocument navigation messages
- Persist nav_bar_visible and context_drawer_visible in config
- Update properties panel with document info display"
2026-01-14 18:53:36 +01:00
wfx
b1b0999ebe chore: refactoring 2026-01-14 17:16:25 +01:00
wfx
aa83e9ab1d fix: misspelling screenshot(s) 2026-01-10 17:53:37 +01:00
wfx
97988a7be6 fix: Adding a Screenshot 2026-01-10 17:46:08 +01:00
wfx
7414caf185 chore: Adding a Screenshot 2026-01-10 17:40:21 +01:00
wfx
823dfe9fa2 Implement comprehensive metadata extraction for raster images with
EXIF support and display in the right panel.

New features:
- Extract basic metadata (filename, format, resolution, file size, color type)
- Parse EXIF data (camera, date, exposure, aperture, ISO, focal length, GPS)
- Display metadata in collapsible right panel (toggle with 'i' key)
- Auto-refresh metadata on document navigation

Changes by file:

Cargo.toml, Cargo.lock:
- Add kamadak-exif dependency for EXIF parsing

i18n/en/noctua.ftl:
- Add translation strings for all metadata labels

src/app/document/meta.rs:
- New module for metadata types (BasicMeta, ExifMeta, DocumentMeta)
- Extraction logic with EXIF parsing via kamadak-exif
- Helper methods for formatted display (resolution, file size, camera, GPS)

src/app/document/mod.rs:
- Re-export meta module

src/app/document/{raster,vector,portable}.rs:
- Add extract_metadata() method stubs (full impl for raster)

src/app/document/file.rs:
- Reset metadata on document change

src/app/message.rs:
- Add ToggleRightPanel and RefreshMetadata messages

src/app/model.rs:
- Add metadata: Option<DocumentMeta> field
- Add show_right_panel: bool field

src/app/update.rs:
- Handle panel toggle and metadata refresh
- Auto-refresh metadata on navigation when panel visible

src/app/view/panels.rs:
- Implement right_panel() with metadata display
- Conditional sections for basic info and EXIF data

src/app/view/canvas.rs:
- Integrate right panel into layout"
2026-01-10 11:46:07 +01:00
wfx
6623a12632 chore: remove some comments 2026-01-08 21:50:44 +01:00
mow
b67b91e25f Rename icon to org.codeberg.wfx.Noctua.svg (matches app ID) 2026-01-08 20:51:56 +01:00
mow
96b4e40b08 Fix: .metainfo.xml: use modern tags, correct icon/screenshot paths 2026-01-08 20:51:25 +01:00
mow
ed414c9923 Fix: .desktop: remove duplicate Type=, add MimeType entries, set StartupWMClas 2026-01-08 20:50:36 +01:00
mow
d8e5ff559d Fix: correct metainfo path, icon destination, add post-install 2026-01-08 20:49:54 +01:00
mow
21974724c3 fix: namming mistake 2026-01-08 20:24:34 +01:00
mow
d8e7b2e72f chore: better crabby icons 2026-01-08 20:07:02 +01:00
wfx
49a17fb342 chore: a crabby icons 2026-01-08 19:57:31 +01:00
wfx
9aa6fe2d37 docs: A ctual screenshot 2026-01-08 19:53:29 +01:00
wfx
7c21e5cf9b fix: filename 2026-01-08 19:51:51 +01:00
wfx
4c10a80b67 refactor: centralize file handling, fix zoom display and cleanup
File handling (document/file.rs):
- move file operations from app/mod.rs to document/file.rs
- add open_file_dialog() for native file picker
- add collect_directory_siblings() for navigation context
- add open_document_from_path() as main entry point

Zoom/View (panels.rs, canvas.rs, model.rs):
- fix zoom display using ViewMode enum
- ViewMode::Fit shows Fit, ActualSize shows 100%, Custom shows percentage

Model/Update cleanup:
- adjust model.rs for new file handling
- update.rs: use centralized file functions
- document/mod.rs: re-exports for file module

i18n:
BB
ctua.ftl with new/changed strings"
A
- update noctua.ftl with new/changed strings"
2026-01-08 12:20:55 +01:00
wfx
4de63d8549 chore: fix license header 2026-01-07 20:42:28 +01:00
wfx
ab93f649bd chore: initial commit 2026-01-07 20:22:49 +01:00
78 changed files with 18393 additions and 0 deletions

15
.gitignore vendored Normal file
View file

@ -0,0 +1,15 @@
.cargo/
*.pdb
**/*.rs.bk
tmp/
debug/
target/
vendor/
vendor.tar
debian/*
!debian/changelog
!debian/control
!debian/copyright
!debian/install
!debian/rules
!debian/source

7880
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

87
Cargo.toml Normal file
View file

@ -0,0 +1,87 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Cargo.toml
#
# Project metadata and dependencies for the Noctua document viewer.
[package]
name = "noctua"
version = "0.1.0"
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:poppler", "dep:cairo-rs"]
full = ["image", "vector", "portable"]
[dependencies]
# Error handling
anyhow = "1"
# 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.20"
simple_logger = "4.2.0"
# i18n / localization
i18n-embed = { version = "0.16", features = [
"fluent-system",
"desktop-requester",
] }
i18n-embed-fl = "0.10"
# Misc utilities
open = "5.3.2"
rust-embed = "8.8.0"
dirs = "5.0"
sha2 = "0.10"
clap = { version = "4.5.54", features = ["derive"] }
env_logger = "0.11.8"
wallpaper = "3.2"
[dependencies.libcosmic]
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" }

674
LICENSE Normal file
View file

@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

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.

4
i18n.toml Normal file
View file

@ -0,0 +1,4 @@
fallback_language = "en"
[fluent]
assets_dir = "i18n"

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 }…

128
i18n/en/noctua.ftl Normal file
View file

@ -0,0 +1,128 @@
# 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 = A document and image viewer for the COSMIC desktop
## Main window
window-title = { $filename ->
[none] Noctua
*[some] { $filename } — Noctua
}
## Menu entries
menu-main = Menu
menu-file-open = Open…
menu-file-quit = Quit
menu-view-zoom-in = Zoom In
menu-view-zoom-out = Zoom Out
menu-view-zoom-reset = Reset Zoom
menu-view-zoom-fit = Fit to Window
menu-view-flip-horizontal = Flip Horizontally
menu-view-flip-vertical = Flip Vertically
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 }%
status-doc-dimensions = { $width } × { $height }
status-nav-position = { $current } / { $total }
status-separator = |
## Placeholders / Empty states
no-document = No document loaded
## Labels
label-zoom = Zoom
label-tools = Tools
label-crop = Crop
label-scale = Scale
label-page = Page
label-pages = Pages
## Loading states
loading-metadata = Loading metadata…
loading-thumbnails = Loading { $current } / { $total }…
## Error messages
error-failed-to-open = Failed to open "{ $path }"
error-unsupported-format = Unsupported file format
error-no-image-loaded = No image loaded
## Properties panel
panel-properties = Properties
panel-actions = Actions
meta-section-file = File Information
meta-section-exif = Camera Information
meta-section-image = Image Information
## File metadata
meta-filename = Name
meta-format = Format
meta-dimensions = Dimensions
meta-filesize = Size
meta-colortype = Color Type
meta-path = Path
meta-pages = Pages
meta-current-page = Current Page
## Image metadata
meta-width = Width
meta-height = Height
meta-depth = Bit Depth
## EXIF metadata
meta-camera = Camera
meta-datetime = Date Taken
meta-exposure = Exposure
meta-aperture = Aperture
meta-iso = ISO { $iso }
meta-focal = Focal Length
meta-gps = GPS Location
## Action buttons
action-set-wallpaper = Set as Wallpaper
action-open-with = Open With…
action-show-in-folder = Show in Folder
## Navigation panel (thumbnails)
nav-panel-title = Pages
nav-panel-loading = Loading { $current } / { $total }…
## Format panel
format-section-title = Paper Format
format-section-subtitle = Select paper size for export
orientation-section-title = Orientation

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

102
justfile Normal file
View file

@ -0,0 +1,102 @@
# Name of the application's binary.
name := 'noctua'
# The unique ID of the application.
appid := 'org.codeberg.wfx.Noctua'
# Path to root file system, which defaults to `/`.
rootdir := ''
# The prefix for the `/usr` directory.
prefix := '/usr'
# The location of the cargo target directory.
cargo-target-dir := env('CARGO_TARGET_DIR', 'target')
# Application's appstream metadata
appdata := appid + '.metainfo.xml'
# Application's desktop entry
desktop := appid + '.desktop'
# Application's icon.
icon-svg := appid + '.svg'
# Install destinations
base-dir := absolute_path(clean(rootdir / prefix))
appdata-dst := base-dir / 'share' / 'metainfo' / appdata
bin-dst := base-dir / 'bin' / name
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 which runs `just build-release`
default: build-release
# Runs `cargo clean`
clean:
cargo clean
# Removes vendored dependencies
clean-vendor:
rm -rf .cargo vendor vendor.tar
# `cargo clean` and removes vendored dependencies
clean-dist: clean clean-vendor
# Compiles with debug profile
build-debug *args:
cargo build {{args}}
# Compiles with release profile
build-release *args: (build-debug '--release' args)
# Compiles release profile with vendored dependencies
build-vendored *args: vendor-extract (build-release '--frozen --offline' args)
# Runs a clippy check
check *args:
cargo clippy --all-features {{args}} -- -W clippy::pedantic
# Runs a clippy check with JSON message format
check-json: (check '--message-format=json')
# Run the application for testing purposes
run *args:
env RUST_BACKTRACE=full cargo run --release {{args}}
# Installs files
install:
install -Dm0755 {{ cargo-target-dir / 'release' / name }} {{bin-dst}}
install -Dm0644 {{ 'resources' / desktop }} {{desktop-dst}}
install -Dm0644 {{ 'resources' / appdata }} {{appdata-dst}}
install -Dm0644 {{ 'resources' / 'icons' / 'hicolor' / 'scalable' / 'apps' / icon-svg }} {{icon-svg-dst}}
# Update icon cache and desktop database after install
post-install:
-gtk-update-icon-cache -f -t {{icons-dst}}
-update-desktop-database {{base-dir / 'share' / 'applications'}}
# Uninstalls installed files
uninstall:
rm -f {{bin-dst}} {{desktop-dst}} {{appdata-dst}} {{icon-svg-dst}}
-gtk-update-icon-cache -f -t {{icons-dst}}
-update-desktop-database {{base-dir / 'share' / 'applications'}}
# Vendor dependencies locally
vendor:
mkdir -p .cargo
cargo vendor | head -n -1 > .cargo/config.toml
echo 'directory = "vendor"' >> .cargo/config.toml
tar pcf vendor.tar vendor
rm -rf vendor
# Extracts vendored dependencies
vendor-extract:
rm -rf vendor
tar pxf vendor.tar
# 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
cargo clean
git add Cargo.lock
git commit -m 'release: {{version}}'
git commit --amend
git tag -a {{version}} -m ''

View file

@ -0,0 +1,359 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="512"
height="512"
viewBox="0 0 512 512.00002"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
sodipodi:docname="noctua.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="0.70710678"
inkscape:cx="371.93817"
inkscape:cy="454.66966"
inkscape:window-width="2151"
inkscape:window-height="1268"
inkscape:window-x="26"
inkscape:window-y="23"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" /><defs
id="defs1"><filter
inkscape:collect="always"
style="color-interpolation-filters:sRGB"
id="filter51"
x="-0.038666843"
y="-0.043024329"
width="1.0773337"
height="1.0860487"><feGaussianBlur
inkscape:collect="always"
stdDeviation="8.526584"
id="feGaussianBlur51" /></filter></defs><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"><g
id="g52"
transform="translate(-3.0243593,1.4705614)"><g
id="g51"
transform="matrix(0.78387977,0,0,0.79126391,717.47018,-314.39252)"
style="mix-blend-mode:normal;filter:url(#filter51)"><path
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:12;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
d="m -419.65433,901.91918 53.49446,-39.90981 35.37546,-128.04173 -57.3272,-10.62458 54.53538,-22.93687 -4.79183,-67.55245 c 0,0 -101.90345,-151.66632 -248.17903,-151.66632 -146.27558,0 -251.17903,156.66632 -251.17903,156.66632 l -1.79181,61.55245 52.53536,23.93687 -52.3272,25.62458 39.10583,114.03065 54.49448,43.90981 z"
id="path27"
sodipodi:nodetypes="cccccccccccccc" /><ellipse
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:10.9304;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="ellipse27"
cx="762.00842"
cy="388.49088"
rx="118.31967"
ry="122.18041"
transform="matrix(-0.8660254,0.5,0.5,0.8660254,0,0)" /><circle
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:9.2322;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="circle27"
cx="763.00842"
cy="369.59668"
r="101.55416"
transform="matrix(-0.8660254,0.5,0.5,0.8660254,0,0)" /><g
id="g28"
transform="matrix(0.83587403,0.4825921,-0.4825921,0.83587403,33.580597,933.65116)"
style="fill:#40f6f8;fill-opacity:1;stroke:none"><ellipse
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:11.3247;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="ellipse28"
cx="-774.25"
cy="190.75"
rx="122.58766"
ry="126.58766" /><circle
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:9.56522;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="circle28"
cx="-774.25"
cy="169.37973"
r="105.21739" /></g><path
id="path28"
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:7.88785;stroke-linecap:square"
d="m -582.02574,955.5487 46.94932,-39.28617 c 2.02674,-1.69594 2.02674,-4.42659 0,-6.12253 l 49.69748,-61.45357 -35.55253,-19.95356 c -17.02674,-12.69595 -27.86062,-14.80883 -69.02121,-11.80767 -22.35449,0.81222 -38.69236,3.51123 -38.69236,3.51123 l -89.6468,33.45357 c -2.02674,1.69594 79.97326,60.67659 82,62.37253 l 46.94932,39.28617 c 2.02674,1.69594 5.29004,1.69594 7.31678,0 z"
sodipodi:nodetypes="ccccccccccc" /><g
id="g32"
transform="matrix(0.60714286,0,0,0.60714286,-264.23676,600.72825)"
style="fill:#40f6f8;fill-opacity:1;stroke:none"><circle
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:13.0486;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="circle29"
cx="-663.80597"
cy="141.60011"
r="56" /><circle
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:10.6602;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="circle30"
cx="-664.05597"
cy="141.35011"
r="45.75" /><circle
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:7.1068;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="circle31"
cx="-660.80597"
cy="136.60011"
r="30.5" /><circle
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:2.85437;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="circle32"
cx="-657.55597"
cy="129.85011"
r="12.25" /></g><path
id="path32"
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:12;stroke-linecap:square;stroke-opacity:1"
d="m -844.86876,512.94355 c 0,0 93.66733,39.64166 114.08139,47.72091 14.6246,5.21818 41.759,33.69181 47.39416,35.68323 6.10156,1.99164 13.64238,-2.0756 19.79311,-0.24154 4.6994,1.25378 68.17055,46.53654 68.17055,46.53654 2.80835,1.93942 7.28846,5.25496 7.49799,8.66147 l 3.21421,52.25444 c 0.20958,3.40649 -1.29711,7.92141 -4.31602,6.32935 l -230.4111,-121.51038 c -3.01886,-1.59204 -6.86858,-2.2072 -7.97268,-5.43662 l -21.76765,-63.66804 c -1.1041,-3.22941 1.28667,-7.90134 4.31604,-6.32936 z"
sodipodi:nodetypes="scccssssssss" /><g
id="g36"
transform="matrix(1.3535534,0,0,1.3535534,152.94895,518.45065)"
style="fill:#40f6f8;fill-opacity:1;stroke:none"><circle
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:13.0486;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="circle33"
cx="456.60492"
cy="141.60011"
r="56"
transform="scale(-1,1)" /><circle
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:10.6602;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="circle34"
cx="456.35492"
cy="141.35011"
r="45.75"
transform="scale(-1,1)" /><circle
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:7.1068;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="circle35"
cx="459.60492"
cy="136.60011"
r="30.5"
transform="scale(-1,1)" /><circle
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:2.85437;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="circle36"
cx="462.85492"
cy="129.85011"
r="12.25"
transform="scale(-1,1)" /></g><path
id="path36"
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:12;stroke-linecap:square;stroke-opacity:1"
d="m -324.90554,504.63387 c 0,0 -92.87343,41.46753 -113.12544,49.94482 -14.51967,5.50344 -41.09151,34.50275 -46.68661,36.60409 -6.0614,2.11069 -13.68038,-1.80817 -19.79405,0.14594 -4.67395,1.34553 -67.24657,47.86201 -67.24657,47.86201 -2.76985,1.99401 -7.18419,5.39662 -7.32701,8.80657 l -2.19077,52.30734 c -0.14285,3.40995 1.45197,7.8945 4.4391,6.24367 L -348.8484,580.5511 c 2.98712,-1.65083 6.82405,-2.34123 7.86474,-5.59164 l 20.51722,-64.08192 c 1.04068,-3.25041 -1.44109,-7.87464 -4.4391,-6.24367 z"
sodipodi:nodetypes="scccssssssss" /><g
id="g39"
transform="matrix(1.3358771,-0.25249858,0.25249858,1.3358771,-891.08015,619.11458)"
style="fill:#40f6f8;fill-opacity:1;stroke:none"><path
id="path37"
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:7.22196;stroke-linecap:square"
d="m 65.751448,168.93619 13.747104,8.20178 c 3.263955,1.94733 5.891615,5.73597 5.891615,8.49469 v 48.96654 c 0,2.75873 -2.62766,3.41194 -5.891615,1.46461 l -13.747104,-8.20178 c -3.263955,-1.94733 -5.891615,-5.73597 -5.891615,-8.49469 V 170.4008 c 0,-2.75873 2.62766,-3.41194 5.891615,-1.46461 z" /><path
id="path38"
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:7.22196;stroke-linecap:square"
d="m 92.001448,185.00099 14.747102,3.82218 c 3.60717,0.93491 5.92875,5.4664 5.89162,8.22487 l -0.75,55.71654 c -0.0371,2.75848 -2.62766,3.53228 -5.89162,1.73443 l -13.747102,-7.57218 c -3.263955,-1.79785 -5.878344,-5.46618 -5.891615,-8.22487 l -0.25,-51.96654 c -0.01327,-2.7587 2.284454,-2.66934 5.891615,-1.73443 z"
sodipodi:nodetypes="sssssssss" /><path
id="path39"
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:8.29698;stroke-linecap:square"
d="m 128.63706,190.89948 8.47588,7.21311 c 3.31861,2.82419 6.73967,6.8681 6.73967,10.05107 v 62.74702 c 0,3.18299 -3.32448,4.39645 -6.73967,1.68984 l -15.72588,-12.46311 c -3.41519,-2.70661 -6.73967,-6.6181 -6.73967,-9.80107 v -57.99702 c 0,-3.18299 10.67106,-4.26403 13.98967,-1.43984 z"
sodipodi:nodetypes="sssssssss" /></g><g
id="g42"
transform="matrix(-1.3358771,-0.25249858,-0.25249858,1.3358771,-283.3154,618.49326)"
style="fill:#40f6f8;fill-opacity:1;stroke:none"><path
id="path40"
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:7.22196;stroke-linecap:square"
d="m 65.751448,168.93619 13.747104,8.20178 c 3.263955,1.94733 5.891615,5.73597 5.891615,8.49469 v 48.96654 c 0,2.75873 -2.62766,3.41194 -5.891615,1.46461 l -13.747104,-8.20178 c -3.263955,-1.94733 -5.891615,-5.73597 -5.891615,-8.49469 V 170.4008 c 0,-2.75873 2.62766,-3.41194 5.891615,-1.46461 z" /><path
id="path41"
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:7.22196;stroke-linecap:square"
d="m 92.001448,185.00099 14.747102,3.82218 c 3.60717,0.93491 5.92875,5.4664 5.89162,8.22487 l -0.75,55.71654 c -0.0371,2.75848 -2.62766,3.53228 -5.89162,1.73443 l -13.747102,-7.57218 c -3.263955,-1.79785 -5.878344,-5.46618 -5.891615,-8.22487 l -0.25,-51.96654 c -0.01327,-2.7587 2.284454,-2.66934 5.891615,-1.73443 z"
sodipodi:nodetypes="sssssssss" /><path
id="path42"
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:8.29698;stroke-linecap:square"
d="m 131.76184,189.76981 7.01824,4.42333 c 3.68654,2.32349 5.07253,10.78755 5.07253,13.97052 v 62.74702 c 0,3.18299 -3.32448,4.39645 -6.73967,1.68984 l -15.72588,-12.46311 c -3.41519,-2.70661 -6.73967,-6.6181 -6.73967,-9.80107 v -57.99702 c 0,-3.18299 13.42791,-4.89299 17.11445,-2.56951 z"
sodipodi:nodetypes="sssssssss" /></g><path
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:10;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
d="m -663.45584,835.09964 76,93 74,-94"
id="path43"
sodipodi:nodetypes="ccc" /><path
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:7;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="m -636.63681,683.04797 33.45534,95.55906"
id="path44"
sodipodi:nodetypes="cc" /><path
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:8;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
d="m -529.62166,679.37557 -34.31316,72.98585"
id="path45"
sodipodi:nodetypes="cc" /><path
id="path46"
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:9.85198;stroke-linecap:square"
d="m -581.93031,719.70018 48.17402,62.55806 c 2.07961,2.57848 2.07961,6.73009 0,9.30853 l -50.38597,88.80066 c -2.07962,2.57849 -5.42804,2.57849 -7.50764,0 l -45.96207,-88.80066 c -2.07961,-2.57844 -2.07961,-6.73005 0,-9.30853 l 48.17402,-62.55806 c 2.07962,-2.57849 5.42802,-2.57849 7.50764,0 z"
sodipodi:nodetypes="ccccccccc" /><path
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:10;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="m -586.54709,481.18742 7.46187,166.80988"
id="path47" /><path
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:10;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="m -626.4966,619.42554 v -55.15433 l -24.04163,-24.04163 h -25.45585"
id="path48" /><path
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:10;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="M -650.53823,540.22958 V 491.06237"
id="path49" /><path
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:10;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="m -533.88643,607.06158 v -42.79037 l 24.04163,-24.04163 h 25.45585"
id="path50"
sodipodi:nodetypes="cccc" /><path
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:10;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="M -509.8448,540.22958 V 497.56237"
id="path51"
sodipodi:nodetypes="cc" /></g><path
style="fill:#9e6d45;fill-opacity:1;stroke:#35414a;stroke-width:8.96455;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
d="m 382.60794,392.14364 39.96282,-29.81446 26.4271,-95.65306 -42.82606,-7.93705 40.74044,-17.1349 -3.57972,-50.46479 c 0,0 -76.12656,-113.301713 -185.40115,-113.301713 -109.27459,0 -187.642299,117.036943 -187.642299,117.036943 l -1.338565,45.98251 39.246334,17.88195 -39.090829,19.14274 29.213862,85.18614 40.709877,32.80265 z"
id="path19"
sodipodi:nodetypes="cccccccccccccc" /><ellipse
style="fill:#f1dfc0;fill-opacity:1;stroke:#35414a;stroke-width:8.16551;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="ellipse9"
cx="-174.40848"
cy="394.37506"
rx="88.390236"
ry="91.274391"
transform="matrix(-0.8660254,0.5,0.5,0.8660254,0,0)" /><circle
style="fill:#f1dfc0;fill-opacity:1;stroke:#35414a;stroke-width:6.89688;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="circle9"
cx="-173.66142"
cy="380.26022"
r="75.865631"
transform="matrix(-0.8660254,0.5,0.5,0.8660254,0,0)" /><g
id="g4"
transform="matrix(0.62443636,0.3605185,-0.3605185,0.62443636,721.19529,415.84889)"
style="fill:#f1dfc0;fill-opacity:1"><ellipse
style="fill:#f1dfc0;fill-opacity:1;stroke:#35414a;stroke-width:11.3247;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="circle4"
cx="-774.25"
cy="190.75"
rx="122.58766"
ry="126.58766" /><circle
style="fill:#f1dfc0;fill-opacity:1;stroke:#35414a;stroke-width:9.56522;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="path3"
cx="-774.25"
cy="169.37973"
r="105.21739" /></g><path
id="path16"
style="fill:#f1dfc0;fill-opacity:1;stroke:#35414a;stroke-width:5.89259;stroke-linecap:square"
d="m 261.30902,432.20736 35.07331,-29.34857 c 1.51407,-1.26695 1.51407,-3.30687 0,-4.57381 l 37.1263,-45.90865 -26.55937,-14.90623 c -12.71976,-9.48446 -20.81317,-11.06287 -51.56202,-8.82087 -16.69984,0.60677 -28.90498,2.62305 -28.90498,2.62305 l -66.97028,24.99136 c -1.51407,1.26694 59.7437,45.3282 61.25777,46.59515 l 35.0733,29.34857 c 1.51407,1.26695 3.95191,1.26695 5.46597,0 z"
sodipodi:nodetypes="ccccccccccc" /><g
id="g11"
transform="matrix(0.45356365,0,0,0.45356365,498.71202,167.14017)"><circle
style="fill:#35414a;fill-opacity:1;stroke:none;stroke-width:13.0486;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="path1"
cx="-663.80597"
cy="141.60011"
r="56" /><circle
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:10.6602;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="circle1"
cx="-664.05597"
cy="141.35011"
r="45.75" /><circle
style="fill:#35414a;fill-opacity:1;stroke:none;stroke-width:7.1068;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="circle2"
cx="-660.80597"
cy="136.60011"
r="30.5" /><circle
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:2.85437;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="circle3"
cx="-657.55597"
cy="129.85011"
r="12.25" /></g><path
id="rect1"
style="fill:#9e6d45;fill-opacity:1;stroke:#35414a;stroke-width:8.96455;stroke-linecap:square;stroke-opacity:1"
d="m 64.95319,101.56096 c 0,0 69.97381,29.61414 85.22405,35.64971 10.92525,3.89822 31.1959,25.16934 35.40562,26.65701 4.55815,1.48785 10.19149,-1.55056 14.78636,-0.18043 3.51067,0.93663 50.92654,34.76493 50.92654,34.76493 2.09797,1.44883 5.44482,3.9257 5.60135,6.47051 l 2.40116,39.03647 c 0.15656,2.54481 -0.969,5.91766 -3.22427,4.72832 L 83.946304,157.91363 c -2.255227,-1.18932 -5.131145,-1.64888 -5.955959,-4.06139 L 61.728909,106.28928 c -0.824813,-2.41252 0.961202,-5.90266 3.224281,-4.72832 z"
sodipodi:nodetypes="scccssssssss" /><g
id="g22"
transform="matrix(1.0111667,0,0,1.0111667,810.36892,105.67502)"><circle
style="fill:#35414a;fill-opacity:1;stroke:none;stroke-width:13.0486;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="circle5"
cx="456.60492"
cy="141.60011"
r="56"
transform="scale(-1,1)" /><circle
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:10.6602;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="circle6"
cx="456.35492"
cy="141.35011"
r="45.75"
transform="scale(-1,1)" /><circle
style="fill:#35414a;fill-opacity:1;stroke:none;stroke-width:7.1068;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="circle7"
cx="459.60492"
cy="136.60011"
r="30.5"
transform="scale(-1,1)" /><circle
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:2.85437;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
id="circle8"
cx="462.85492"
cy="129.85011"
r="12.25"
transform="scale(-1,1)" /></g><path
id="path9"
style="fill:#9e6d45;fill-opacity:1;stroke:#35414a;stroke-width:8.96455;stroke-linecap:square;stroke-opacity:1"
d="m 453.38965,95.353247 c 0,0 -69.38073,30.978153 -84.50991,37.311073 -10.84687,4.11133 -30.69725,25.77515 -34.87705,27.34494 -4.52814,1.57679 -10.21987,-1.35078 -14.78707,0.10903 -3.49165,1.00517 -50.23628,35.75511 -50.23628,35.75511 -2.0692,1.48963 -5.36692,4.03153 -5.47361,6.57892 l -1.63661,39.07599 c -0.10671,2.54739 1.08469,5.89755 3.31621,4.6643 l 170.3179,-94.12571 c 2.23151,-1.23324 5.09788,-1.749 5.87532,-4.1772 l 15.32731,-47.87215 c 0.77743,-2.4282 -1.07656,-5.882717 -3.31621,-4.664303 z"
sodipodi:nodetypes="scccssssssss" /><g
id="g12"
transform="matrix(0.99796166,-0.18862806,0.18862806,0.99796166,30.431155,180.8756)"
style="fill:#f1dfc0;fill-opacity:1"><path
id="rect11"
style="fill:#f1dfc0;fill-opacity:1;stroke:#35414a;stroke-width:7.22196;stroke-linecap:square"
d="m 65.751448,168.93619 13.747104,8.20178 c 3.263955,1.94733 5.891615,5.73597 5.891615,8.49469 v 48.96654 c 0,2.75873 -2.62766,3.41194 -5.891615,1.46461 l -13.747104,-8.20178 c -3.263955,-1.94733 -5.891615,-5.73597 -5.891615,-8.49469 V 170.4008 c 0,-2.75873 2.62766,-3.41194 5.891615,-1.46461 z" /><path
id="path11"
style="fill:#f1dfc0;fill-opacity:1;stroke:#35414a;stroke-width:7.22196;stroke-linecap:square"
d="m 92.001448,185.00099 14.747102,3.82218 c 3.60717,0.93491 5.92875,5.4664 5.89162,8.22487 l -0.75,55.71654 c -0.0371,2.75848 -2.62766,3.53228 -5.89162,1.73443 l -13.747102,-7.57218 c -3.263955,-1.79785 -5.878344,-5.46618 -5.891615,-8.22487 l -0.25,-51.96654 c -0.01327,-2.7587 2.284454,-2.66934 5.891615,-1.73443 z"
sodipodi:nodetypes="sssssssss" /><path
id="path12"
style="fill:#f1dfc0;fill-opacity:1;stroke:#35414a;stroke-width:8.29698;stroke-linecap:square"
d="m 128.63706,190.89948 8.47588,7.21311 c 3.31861,2.82419 6.73967,6.8681 6.73967,10.05107 v 62.74702 c 0,3.18299 -3.32448,4.39645 -6.73967,1.68984 l -15.72588,-12.46311 c -3.41519,-2.70661 -6.73967,-6.6181 -6.73967,-9.80107 v -57.99702 c 0,-3.18299 10.67106,-4.26403 13.98967,-1.43984 z"
sodipodi:nodetypes="sssssssss" /></g><g
id="g15"
transform="matrix(-0.99796166,-0.18862806,-0.18862806,0.99796166,484.45939,180.41145)"
style="fill:#f1dfc0;fill-opacity:1"><path
id="path13"
style="fill:#f1dfc0;fill-opacity:1;stroke:#35414a;stroke-width:7.22196;stroke-linecap:square"
d="m 65.751448,168.93619 13.747104,8.20178 c 3.263955,1.94733 5.891615,5.73597 5.891615,8.49469 v 48.96654 c 0,2.75873 -2.62766,3.41194 -5.891615,1.46461 l -13.747104,-8.20178 c -3.263955,-1.94733 -5.891615,-5.73597 -5.891615,-8.49469 V 170.4008 c 0,-2.75873 2.62766,-3.41194 5.891615,-1.46461 z" /><path
id="path14"
style="fill:#f1dfc0;fill-opacity:1;stroke:#35414a;stroke-width:7.22196;stroke-linecap:square"
d="m 92.001448,185.00099 14.747102,3.82218 c 3.60717,0.93491 5.92875,5.4664 5.89162,8.22487 l -0.75,55.71654 c -0.0371,2.75848 -2.62766,3.53228 -5.89162,1.73443 l -13.747102,-7.57218 c -3.263955,-1.79785 -5.878344,-5.46618 -5.891615,-8.22487 l -0.25,-51.96654 c -0.01327,-2.7587 2.284454,-2.66934 5.891615,-1.73443 z"
sodipodi:nodetypes="sssssssss" /><path
id="path15"
style="fill:#f1dfc0;fill-opacity:1;stroke:#35414a;stroke-width:8.29698;stroke-linecap:square"
d="m 131.76184,189.76981 7.01824,4.42333 c 3.68654,2.32349 5.07253,10.78755 5.07253,13.97052 v 62.74702 c 0,3.18299 -3.32448,4.39645 -6.73967,1.68984 l -15.72588,-12.46311 c -3.41519,-2.70661 -6.73967,-6.6181 -6.73967,-9.80107 v -57.99702 c 0,-3.18299 13.42791,-4.89299 17.11445,-2.56951 z"
sodipodi:nodetypes="sssssssss" /></g><path
style="fill:none;fill-opacity:1;stroke:#35414a;stroke-width:7.47046;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
d="m 200.47699,342.22637 56.7755,69.47528 55.28141,-70.22232"
id="path17"
sodipodi:nodetypes="ccc" /><path
style="fill:#9e6d45;fill-opacity:1;stroke:#35414a;stroke-width:5.22932;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="m 220.51204,228.63678 24.99268,71.38701"
id="path20"
sodipodi:nodetypes="cc" /><path
style="fill:#9e6d45;fill-opacity:1;stroke:#35414a;stroke-width:5.97637;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
d="m 300.45728,225.89333 -25.63351,54.52379"
id="path21"
sodipodi:nodetypes="cc" /><path
id="rect4"
style="fill:#9e6d45;fill-opacity:1;stroke:#35414a;stroke-width:7.35988;stroke-linecap:square"
d="m 261.38032,256.01767 35.98821,46.73375 c 1.55356,1.92624 1.55356,5.02768 0,6.9539 l -37.64064,66.33818 c -1.55357,1.92625 -4.055,1.92625 -5.60856,0 l -34.33578,-66.33818 c -1.55356,-1.92622 -1.55356,-5.02766 0,-6.9539 l 35.98821,-46.73375 c 1.55357,-1.92625 4.05498,-1.92625 5.60856,0 z"
sodipodi:nodetypes="ccccccccc" /><path
style="fill:#35414a;fill-opacity:1;stroke:#35414a;stroke-width:7.47046;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="m 257.93137,77.837667 5.57436,124.614653"
id="path22" /><path
style="fill:none;fill-opacity:1;stroke:#35414a;stroke-width:7.47046;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="m 228.08725,181.1079 v -41.20282 l -17.96021,-17.9602 h -19.01669"
id="path23" /><path
style="fill:none;fill-opacity:1;stroke:#35414a;stroke-width:7.47046;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="M 210.12704,121.94488 V 85.21471"
id="path24" /><path
style="fill:none;fill-opacity:1;stroke:#35414a;stroke-width:7.47046;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="m 297.2713,171.87146 v -31.96638 l 17.96021,-17.9602 h 19.01669"
id="path25"
sodipodi:nodetypes="cccc" /><path
style="fill:none;fill-opacity:1;stroke:#35414a;stroke-width:7.47046;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="M 315.23151,121.94488 V 90.070509"
id="path26"
sodipodi:nodetypes="cc" /></g></g></svg>

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +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=noctua %F
Terminal=false
StartupNotify=true
Categories=Graphics;Viewer;Utility;
StartupWMClass=org.codeberg.wfx.Noctua
Keywords=image;document;pdf;viewer;cosmic;
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

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!-- resources/app.metainfo.xml -->
<!-- AppStream metadata for software centers (e.g., COSMIC Store, GNOME Software, KDE Discover). -->
<component type="desktop-application">
<id>org.codeberg.wfx.Noctua</id>
<metadata_license>CC0-1.0</metadata_license>
<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>
<url type="homepage">https://codeberg.org/wfx/noctua</url>
<url type="bugtracker">https://codeberg.org/wfx/noctua/issues</url>
<url type="vcs-browser">https://codeberg.org/wfx/noctua</url>
<provides>
<binary>noctua</binary>
<mediatype>image/png</mediatype>
<mediatype>image/jpeg</mediatype>
<mediatype>image/gif</mediatype>
<mediatype>image/webp</mediatype>
<mediatype>image/bmp</mediatype>
<mediatype>image/tiff</mediatype>
<mediatype>image/svg+xml</mediatype>
<mediatype>application/pdf</mediatype>
</provides>
<requires>
<display_length compare="ge">360</display_length>
</requires>
<supports>
<control>keyboard</control>
<control>pointing</control>
<control>touch</control>
</supports>
<categories>
<category>Graphics</category>
<category>Viewer</category>
<category>Utility</category>
</categories>
<keywords>
<keyword>image</keyword>
<keyword>document</keyword>
<keyword>pdf</keyword>
<keyword>viewer</keyword>
<keyword>cosmic</keyword>
</keywords>
<content_rating type="oars-1.1"/>
</component>

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

@ -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

@ -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.

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
}
}

43
src/main.rs Normal file
View file

@ -0,0 +1,43 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/main.rs
//
// Application entry point.
mod ui;
mod application;
mod domain;
mod infrastructure;
mod config;
mod i18n;
use anyhow::Result;
use clap::Parser;
use cosmic::app::Settings;
use crate::ui::NoctuaApp;
#[derive(Parser, Debug, Clone)]
#[command(version, about)]
pub struct Args {
/// File to open on startup
#[arg(value_name = "FILE")]
pub file: Option<std::path::PathBuf>,
/// UI language (e.g. "en", "de")
#[arg(short, long, default_value = "en")]
pub language: String,
}
fn main() -> Result<()> {
// Get the system's preferred languages.
let requested_languages = i18n_embed::DesktopLanguageRequester::requested_languages();
// Enable localizations to be applied.
i18n::init(&requested_languages);
env_logger::init();
let args = Args::parse();
cosmic::app::run::<NoctuaApp>(Settings::default(), ui::app::Flags::Args(args))
.map_err(|e| anyhow::anyhow!(e))
}

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;