diff --git a/Cargo.lock b/Cargo.lock index 5043e86..8be9926 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -883,6 +883,31 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.10.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + [[package]] name = "calloop" version = "0.13.0" @@ -952,6 +977,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -1944,7 +1979,7 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" dependencies = [ - "toml", + "toml 0.5.11", ] [[package]] @@ -2315,6 +2350,19 @@ dependencies = [ "weezl", ] +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -2332,6 +2380,53 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.10.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + [[package]] name = "glow" version = "0.13.1" @@ -2353,6 +2448,17 @@ dependencies = [ "gl_generator", ] +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + [[package]] name = "gpu-alloc" version = "0.6.0" @@ -2740,7 +2846,7 @@ dependencies = [ "iced_graphics", "kurbo 0.10.4", "log", - "resvg", + "resvg 0.42.0", "rustc-hash 2.1.1", "softbuffer", "tiny-skia", @@ -2764,7 +2870,7 @@ dependencies = [ "lyon", "once_cell", "raw-window-handle", - "resvg", + "resvg 0.42.0", "rustc-hash 2.1.1", "rustix 0.38.44", "thiserror 1.0.69", @@ -2975,6 +3081,12 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "029d73f573d8e8d63e6d5020011d3255b28c3ba85d6cf870a07184ed23de9284" +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + [[package]] name = "imgref" version = "1.12.0" @@ -3690,6 +3802,7 @@ name = "noctua" version = "0.1.0" dependencies = [ "anyhow", + "cairo-rs", "clap", "dirs 5.0.1", "env_logger", @@ -3701,7 +3814,10 @@ dependencies = [ "libcosmic", "log", "open", + "poppler", + "resvg 0.45.1", "rust-embed", + "sha2", "simple_logger", "tokio", "wallpaper", @@ -4506,6 +4622,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "poppler" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6580b718aba679b295299567119284d534d7bfc7510259d0c72273879831da8d" +dependencies = [ + "cairo-rs", + "glib", +] + [[package]] name = "portable-atomic" version = "1.13.0" @@ -4561,6 +4687,16 @@ dependencies = [ "toml_edit 0.19.15", ] +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -4570,6 +4706,30 @@ dependencies = [ "toml_edit 0.23.10+spec-1.0.0", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -4963,7 +5123,24 @@ dependencies = [ "rgb", "svgtypes", "tiny-skia", - "usvg", + "usvg 0.42.0", +] + +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "gif 0.13.3", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg 0.45.1", + "zune-jpeg 0.4.21", ] [[package]] @@ -5138,8 +5315,26 @@ dependencies = [ "bytemuck", "smallvec", "ttf-parser 0.21.1", - "unicode-bidi-mirroring", - "unicode-ccc", + "unicode-bidi-mirroring 0.2.0", + "unicode-ccc 0.2.0", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser 0.25.1", + "unicode-bidi-mirroring 0.4.0", + "unicode-ccc 0.4.0", "unicode-properties", "unicode-script", ] @@ -5239,6 +5434,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -5590,6 +5794,19 @@ dependencies = [ "libc", ] +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + [[package]] name = "taffy" version = "0.9.2" @@ -5602,6 +5819,12 @@ dependencies = [ "slotmap", ] +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + [[package]] name = "tempfile" version = "3.24.0" @@ -5826,10 +6049,25 @@ dependencies = [ ] [[package]] -name = "toml_datetime" -version = "0.6.11" +name = "toml" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] [[package]] name = "toml_datetime" @@ -5847,7 +6085,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap", - "toml_datetime 0.6.11", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.3", "winnow 0.5.40", ] @@ -5976,12 +6227,24 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86" +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + [[package]] name = "unicode-ccc" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656" +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -6059,12 +6322,39 @@ dependencies = [ "data-url", "flate2", "fontdb 0.18.0", - "imagesize", + "imagesize 0.12.0", "kurbo 0.11.3", "log", "pico-args", "roxmltree", - "rustybuzz", + "rustybuzz 0.14.1", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "fontdb 0.23.0", + "imagesize 0.13.0", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree", + "rustybuzz 0.20.1", "simplecss", "siphasher", "strict-num", @@ -6110,6 +6400,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index bea26a7..302b930 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,11 @@ i18n-embed-fl = "0.10" open = "5.3.2" rust-embed = "8.8.0" dirs = "5.0" +sha2 = "0.10" image = "0.25.9" +poppler = { version = "0.4", features = ["render"] } +cairo-rs = { version = "0.18", features = ["png"] } +resvg = "0.45" clap = { version = "4.5.54", features = ["derive"] } env_logger = "0.11.8" wallpaper = "3.2" diff --git a/README.md b/README.md index b7ce178..cd55fae 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,27 @@ A [justfile](./justfile) is included by default for the [casey/just][just] comma - `just check` runs clippy on the project to check for linter warnings - `just check-json` can be used by IDEs that support LSP +### Dependencies +#### Arch Linux +```bash +sudo pacman -S poppler-glib +``` + +#### Debian/Ubuntu +```bash +sudo apt install libpoppler-glib-dev +``` + +#### Fedora +```bash +sudo dnf install poppler-glib-devel +``` + +#### OpenSUSE +```bash +sudo zypper install poppler-glib-devel +``` + ## Documentation - [Usage](docs/usage.md) diff --git a/docs/features.md b/docs/features.md index 932eeed..7835bea 100644 --- a/docs/features.md +++ b/docs/features.md @@ -181,17 +181,24 @@ Full keyboard-driven workflow: - Copy/Move/Delete operations - Drag-and-drop support -#### Error Handling -- User-friendly error messages (ShowError/ClearError prepared) -- Graceful handling of corrupted files -- Recovery suggestions - ### Medium Priority #### Multi-format TIFF Support - Multi-page TIFF navigation - Page thumbnails +#### 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 - Thumbnail strip - Grid view for folder contents @@ -202,18 +209,6 @@ Full keyboard-driven workflow: - Configurable intervals - Fullscreen support -### Low Priority - -#### Advanced Editing -- Crop tool (message prepared) -- Scale/Resize tool (message prepared) -- Basic color adjustments - -#### Metadata Editing -- EXIF data modification -- Comment annotations -- Tag management - ## Feature Status Legend - **Implemented**: Fully functional and tested diff --git a/docs/usage.md b/docs/usage.md index 9524677..f1036be 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -170,7 +170,7 @@ The following features are prepared in code but not yet implemented: ### File Operations - File open dialog - Save transformed images -- Copy/Move/Delete operations +- (Copy/Move/)Delete operations ### Document Support - SVG rendering with `resvg` diff --git a/i18n/en/noctua.ftl b/i18n/en/noctua.ftl index f736b39..ec6f9d3 100644 --- a/i18n/en/noctua.ftl +++ b/i18n/en/noctua.ftl @@ -1,55 +1,92 @@ # SPDX-License-Identifier: GPL-3.0-or-later # i18n/en/noctua.ftl # -# Localization strings for Noctua's user interface (English). +# Localization strings for Noctua (English). +# Usage: fl!("message-id", arg1, arg2, ...) +# +# Positional arguments ($1, $2, ...) are used for variable content. -## Application metadata +## Application noctua-app-name = Noctua -noctua-app-description = A wise document and image viewer for the COSMIC™ desktop +noctua-app-description = A document and image viewer for the COSMIC desktop + ## Main window window-title = { $filename -> - *[none] Noctua - *[some] { $filename } — Noctua + [none] Noctua + *[some] { $filename } — Noctua } + ## Menu entries 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 -## Placeholders / empty states + +## 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 -zoom = Zoom -tools = Tools -crop = Crop -scale = Scale +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-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 -## Action buttons -action-set-wallpaper = Set as Wallpaper -action-open-with = Open With… -action-show-in-folder = Show in Folder - -## Basic metadata +## File metadata meta-filename = Name meta-format = Format meta-dimensions = Dimensions @@ -59,14 +96,26 @@ 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 +meta-iso = ISO { $iso } meta-focal = Focal Length meta-gps = GPS Location -## States -loading-metadata = Loading... +## 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 }… diff --git a/src/app/document/cache.rs b/src/app/document/cache.rs new file mode 100644 index 0000000..6660a6c --- /dev/null +++ b/src/app/document/cache.rs @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/app/document/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 super::ImageHandle; +use crate::constant::{CACHE_DIR, THUMBNAIL_EXT}; + +/// Get the cache directory path (~/.cache/noctua/). +fn cache_dir() -> Option { + dirs::cache_dir().map(|p| p.join(CACHE_DIR)) +} + +/// Ensure the cache directory exists. +fn ensure_cache_dir() -> Option { + let dir = 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: u32) -> Option { + 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!("{:x}", hash)) +} + +/// Get the full path for a cached thumbnail. +fn thumbnail_path(file_path: &Path, page: u32) -> Option { + let dir = cache_dir()?; + let key = cache_key(file_path, page)?; + Some(dir.join(format!("{}.{}", key, THUMBNAIL_EXT))) +} + +/// Load a thumbnail from disk cache. +/// Returns None if not cached or cache is invalid. +pub fn load_thumbnail(file_path: &Path, page: u32) -> Option { + let cache_path = 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(super::create_image_handle(&img)) +} + +/// Save a thumbnail to disk cache. +pub fn save_thumbnail(file_path: &Path, page: u32, image: &DynamicImage) -> Option<()> { + let dir = ensure_cache_dir()?; + let key = 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 + } + } +} + +/// Check if a thumbnail exists in cache. +pub fn has_thumbnail(file_path: &Path, page: u32) -> bool { + thumbnail_path(file_path, page) + .map(|p| p.exists()) + .unwrap_or(false) +} + +/// Clear all cached thumbnails. +#[allow(dead_code)] +pub fn clear_cache() -> std::io::Result<()> { + if let Some(dir) = cache_dir() { + if dir.exists() { + fs::remove_dir_all(&dir)?; + } + } + Ok(()) +} diff --git a/src/app/document/file.rs b/src/app/document/file.rs index 3747bc8..371c525 100644 --- a/src/app/document/file.rs +++ b/src/app/document/file.rs @@ -19,8 +19,8 @@ use crate::app::model::{AppModel, ViewMode}; /// /// Raster formats are delegated to the `image` crate, which decides /// based on enabled codecs (e.g. default-formats). -pub fn open_document(path: PathBuf) -> anyhow::Result { - let kind = DocumentKind::from_path(&path) +pub fn open_document(path: &Path) -> anyhow::Result { + let kind = DocumentKind::from_path(path) .ok_or_else(|| anyhow!("Unsupported document type: {:?}", path))?; let content = match kind { @@ -88,11 +88,13 @@ pub fn open_single_file(model: &mut AppModel, path: &Path) { /// Load a document into the model, resetting view state. fn load_document_into_model(model: &mut AppModel, path: &Path) { - match open_document(path.to_path_buf()) { + match open_document(path) { Ok(doc) => { + // Extract metadata before storing the document. + let metadata = doc.extract_meta(path); + model.document = Some(doc); - // Reset cached metadata so it gets reloaded when panel is visible. - model.metadata = None; + model.metadata = Some(metadata); model.current_path = Some(path.to_path_buf()); model.clear_error(); @@ -102,6 +104,7 @@ fn load_document_into_model(model: &mut AppModel, path: &Path) { } Err(err) => { model.document = None; + model.metadata = None; model.current_path = None; model.set_error(err.to_string()); } diff --git a/src/app/document/meta.rs b/src/app/document/meta.rs index 162012e..0eb738c 100644 --- a/src/app/document/meta.rs +++ b/src/app/document/meta.rs @@ -10,6 +10,7 @@ use image::DynamicImage; use exif::{In, Reader as ExifReader, Tag, Value}; use super::file; +use crate::constant::{MINUTES_PER_DEGREE, SECONDS_PER_DEGREE}; /// Basic document metadata (always available). #[derive(Debug, Clone)] @@ -189,7 +190,7 @@ fn extract_gps_coord(exif: &exif::Exif, coord_tag: Tag, ref_tag: Tag) -> Option< let d = rats[0].to_f64(); let m = rats[1].to_f64(); let s = rats[2].to_f64(); - d + m / 60.0 + s / 3600.0 + d + m / MINUTES_PER_DEGREE + s / SECONDS_PER_DEGREE } _ => return None, }; diff --git a/src/app/document/mod.rs b/src/app/document/mod.rs index e5faa21..0f98802 100644 --- a/src/app/document/mod.rs +++ b/src/app/document/mod.rs @@ -3,16 +3,16 @@ // // Document module root: common enums and type erasure for document kinds. +pub mod cache; pub mod file; pub mod meta; pub mod portable; pub mod raster; -pub mod transform; pub mod utils; pub mod vector; -use cosmic::iced::widget::image as iced_image; use cosmic::iced_renderer::graphics::image::image_rs::ImageFormat as CosmicImageFormat; +use image::GenericImageView; use std::fmt; use std::path::Path; @@ -20,6 +20,41 @@ use self::portable::PortableDocument; use self::raster::RasterDocument; use self::vector::VectorDocument; +/// Trait for documents that support multiple pages (PDF, multi-page TIFF, etc.). +pub trait MultiPage { + /// Total number of pages in the document. + fn page_count(&self) -> u32; + + /// Current page index (0-based). + fn current_page(&self) -> u32; + + /// Navigate to a specific page. + fn goto_page(&mut self, page: u32) -> anyhow::Result<()>; + + /// Check if thumbnails are ready for display. + fn thumbnails_ready(&self) -> bool; + + /// Generate thumbnails (uses disk cache when available). + fn generate_thumbnails(&mut self); + + /// Get cached thumbnail handle for a specific page. + fn get_thumbnail(&self, page: u32) -> Option; +} + +/// Re-export the image handle type for use by submodules. +pub type ImageHandle = cosmic::iced::widget::image::Handle; + +/// Create an iced image handle from a DynamicImage. +/// +/// This is the central function for converting rendered images to display handles. +/// Used by raster, vector, and portable document types. +pub fn create_image_handle(img: &image::DynamicImage) -> ImageHandle { + let (w, h) = img.dimensions(); + let rgba = img.to_rgba8(); + let pixels = rgba.into_raw(); + ImageHandle::from_rgba(w, h, pixels) +} + /// High-level classification of documents. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DocumentKind { @@ -79,9 +114,9 @@ impl DocumentContent { /// Returns a cloneable image handle for rendering. /// /// This is intentionally linear: every concrete document type - /// owns some kind of `iced_image::Handle`, and the canvas can + /// owns some kind of `ImageHandle`, and the canvas can /// just call `doc.handle()` without additional branching. - pub fn handle(&self) -> iced_image::Handle { + pub fn handle(&self) -> ImageHandle { match self { DocumentContent::Raster(doc) => doc.handle.clone(), DocumentContent::Vector(doc) => doc.handle.clone(), @@ -101,44 +136,136 @@ impl DocumentContent { } } /// Extract metadata from the document. - /// This may involve file I/O for EXIF data, so call lazily. - pub fn extract_meta(&self) -> meta::DocumentMeta { + /// Requires the file path for file size and EXIF extraction. + pub fn extract_meta(&self, path: &Path) -> meta::DocumentMeta { match self { - DocumentContent::Raster(doc) => doc.extract_meta(), - DocumentContent::Vector(doc) => doc.extract_meta(), - DocumentContent::Portable(doc) => doc.extract_meta(), + DocumentContent::Raster(doc) => doc.extract_meta(path), + DocumentContent::Vector(doc) => doc.extract_meta(path), + DocumentContent::Portable(doc) => doc.extract_meta(path), + } + } + + /// Rotate document 90 degrees clockwise. + pub fn rotate_cw(&mut self) { + match self { + DocumentContent::Raster(doc) => doc.rotate_cw(), + DocumentContent::Vector(doc) => doc.rotate_cw(), + DocumentContent::Portable(doc) => doc.rotate_cw(), + } + } + + /// Rotate document 90 degrees counter-clockwise. + pub fn rotate_ccw(&mut self) { + match self { + DocumentContent::Raster(doc) => doc.rotate_ccw(), + DocumentContent::Vector(doc) => doc.rotate_ccw(), + DocumentContent::Portable(doc) => doc.rotate_ccw(), + } + } + + /// Flip document horizontally. + pub fn flip_horizontal(&mut self) { + match self { + DocumentContent::Raster(doc) => doc.flip_horizontal(), + DocumentContent::Vector(doc) => doc.flip_horizontal(), + DocumentContent::Portable(doc) => doc.flip_horizontal(), + } + } + + /// Flip document vertically. + pub fn flip_vertical(&mut self) { + match self { + DocumentContent::Raster(doc) => doc.flip_vertical(), + DocumentContent::Vector(doc) => doc.flip_vertical(), + DocumentContent::Portable(doc) => doc.flip_vertical(), + } + } + + /// Check if this document supports multiple pages. + pub fn is_multi_page(&self) -> bool { + match self { + DocumentContent::Portable(doc) => doc.page_count() > 1, + // TODO: RasterDocument for multi-page TIFF + _ => false, + } + } + + /// Get page count if this is a multi-page document. + pub fn page_count(&self) -> Option { + match self { + DocumentContent::Portable(doc) => Some(doc.page_count()), + // TODO: RasterDocument for multi-page TIFF + _ => None, + } + } + + /// Get current page index if this is a multi-page document. + pub fn current_page(&self) -> Option { + match self { + DocumentContent::Portable(doc) => Some(doc.current_page()), + // TODO: RasterDocument for multi-page TIFF + _ => None, + } + } + + /// Navigate to a specific page if this is a multi-page document. + pub fn goto_page(&mut self, page: u32) -> anyhow::Result<()> { + match self { + DocumentContent::Portable(doc) => doc.goto_page(page), + // TODO: RasterDocument for multi-page TIFF + _ => Err(anyhow::anyhow!("Document does not support multiple pages")), + } + } + + /// Get cached thumbnail handle for a specific page. + pub fn get_thumbnail(&self, page: u32) -> Option { + match self { + DocumentContent::Portable(doc) => doc.get_thumbnail(page), + // TODO: RasterDocument for multi-page TIFF + _ => None, + } + } + + /// Check if thumbnails are ready for display. + pub fn thumbnails_ready(&self) -> bool { + match self { + DocumentContent::Portable(doc) => doc.thumbnails_ready(), + // TODO: RasterDocument for multi-page TIFF + _ => false, + } + } + + /// Get number of thumbnails currently loaded. + pub fn thumbnails_loaded(&self) -> u32 { + match self { + DocumentContent::Portable(doc) => doc.thumbnails_loaded(), + // TODO: RasterDocument for multi-page TIFF + _ => 0, + } + } + + /// Generate a single thumbnail page. Returns next page to generate, or None if done. + pub fn generate_thumbnail_page(&mut self, page: u32) -> Option { + match self { + DocumentContent::Portable(doc) => doc.generate_thumbnail_page(page), + // TODO: RasterDocument for multi-page TIFF + _ => None, + } + } + + /// Generate all thumbnails at once (blocking). + pub fn generate_thumbnails(&mut self) { + match self { + DocumentContent::Portable(doc) => doc.generate_thumbnails(), + // TODO: RasterDocument for multi-page TIFF + _ => {} } } } /// Set an image file as desktop wallpaper. /// -/// This function attempts multiple 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) -/// -/// The operation is performed asynchronously and logs success/failure. +/// Delegates to `utils::set_as_wallpaper` which tries multiple methods. 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; - } - }; - - // Convert to string - let path_str = match abs_path.to_str() { - Some(s) => s.to_string(), - None => { - log::error!("Invalid UTF-8 in path: {}", abs_path.display()); - return; - } - }; - - // Delegate to utils with concrete string type - utils::set_as_wallpaper(&path_str); + utils::set_as_wallpaper(path); } diff --git a/src/app/document/portable.rs b/src/app/document/portable.rs index 129f5d5..0f690d1 100644 --- a/src/app/document/portable.rs +++ b/src/app/document/portable.rs @@ -1,71 +1,317 @@ // SPDX-License-Identifier: GPL-3.0-or-later // src/app/document/portable.rs // -// Portable documents (e.g. PDF) – basic model and rendering stub. +// Portable documents (PDF) with poppler backend. -use std::path::PathBuf; +use std::io::Cursor; +use std::path::{Path, PathBuf}; -use cosmic::iced::widget::image as iced_image; -use image::{GenericImageView, DynamicImage}; +use cairo::{Context, Format, ImageSurface}; +use image::{imageops, DynamicImage, ImageReader}; +use poppler::PopplerDocument; + +use super::{cache, ImageHandle}; +use crate::constant::{FULL_ROTATION, PDF_RENDER_SCALE, PDF_THUMBNAIL_SCALE, ROTATION_STEP}; /// Represents a portable document (PDF). pub struct PortableDocument { - pub path: PathBuf, - pub page_count: u32, - pub current_page: u32, - pub rotation: i32, // 0, 90, 180, 270; kept for future backend integration + /// The parsed PDF document. + document: PopplerDocument, + /// Path to the source file (for caching). + source_path: PathBuf, + /// Total number of pages. + page_count: u32, + /// Current page index (0-based). + current_page: u32, + /// Rotation in degrees (0, 90, 180, 270). + pub rotation: i16, + /// Current rendered page as image. pub rendered: DynamicImage, - pub handle: iced_image::Handle, - // TODO: internal PDF handle from chosen backend + /// Image handle for display. + pub handle: ImageHandle, + /// Cached thumbnail handles for each page (None = not yet generated). + thumbnail_cache: Option>, } impl PortableDocument { - /// Open a portable document and render the first page. - /// - /// Currently this uses a dummy 1x1 transparent image as placeholder. - pub fn open(path: PathBuf) -> anyhow::Result { - // TODO: open PDF and render first page using a proper backend. - let dummy = DynamicImage::new_rgba8(1, 1); - let handle = Self::build_handle(&dummy); + /// Open a PDF document and render the first page. + pub fn open(path: &Path) -> anyhow::Result { + let document = PopplerDocument::new_from_file(path, None) + .map_err(|e| anyhow::anyhow!("Failed to parse PDF: {}", e))?; + + let page_count = document.get_n_pages() as u32; + if page_count == 0 { + return Err(anyhow::anyhow!("PDF has no pages")); + } + + let rendered = Self::render_page(&document, 0, 0)?; + let handle = super::create_image_handle(&rendered); Ok(Self { - path, - page_count: 1, // TODO: query real page count from backend + document, + source_path: path.to_path_buf(), + page_count, current_page: 0, rotation: 0, - rendered: dummy, + rendered, handle, + thumbnail_cache: None, }) } - /// Construct an iced image handle from a DynamicImage. - fn build_handle(img: &DynamicImage) -> iced_image::Handle { - let (w, h) = img.dimensions(); - let rgba = img.to_rgba8(); - let pixels = rgba.into_raw(); - iced_image::Handle::from_rgba(w, h, pixels) + /// Check if all thumbnails are ready. + pub fn thumbnails_ready(&self) -> bool { + self.thumbnail_cache + .as_ref() + .map(|c| c.len() as u32 >= self.page_count) + .unwrap_or(false) + } + + /// Get the number of thumbnails currently loaded. + pub fn thumbnails_loaded(&self) -> u32 { + self.thumbnail_cache + .as_ref() + .map(|c| c.len() as u32) + .unwrap_or(0) + } + + /// Initialize thumbnail cache (empty, ready for incremental loading). + pub fn init_thumbnail_cache(&mut self) { + if self.thumbnail_cache.is_none() { + self.thumbnail_cache = Some(Vec::with_capacity(self.page_count as usize)); + } + } + + /// Generate a single thumbnail page. Returns the next page to generate, or None if done. + pub fn generate_thumbnail_page(&mut self, page: u32) -> Option { + // 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 as usize >= cache.len() && page < self.page_count + }; + + 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.page_count { + Some(next) + } else { + None + } + } + + /// Generate all thumbnails at once (legacy, blocking). + pub fn generate_thumbnails(&mut self) { + if self.thumbnails_ready() { + return; + } + self.init_thumbnail_cache(); + for page in 0..self.page_count { + self.generate_thumbnail_page(page); + } + } + + /// Load thumbnail from cache or generate and cache it. + fn load_or_generate_thumbnail(&self, page: u32) -> ImageHandle { + if let Some(handle) = cache::load_thumbnail(&self.source_path, page) { + return handle; + } + + match Self::render_page_at_scale(&self.document, page, 0, PDF_THUMBNAIL_SCALE) { + Ok(img) => { + let _ = cache::save_thumbnail(&self.source_path, page, &img); + super::create_image_handle(&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: u32, + rotation: i16, + ) -> anyhow::Result { + Self::render_page_at_scale(document, page_index, rotation, PDF_RENDER_SCALE) + } + + /// Render a specific page at a given scale. + fn render_page_at_scale( + document: &PopplerDocument, + page_index: u32, + rotation: i16, + scale: f64, + ) -> anyhow::Result { + let page = document + .get_page(page_index as usize) + .ok_or_else(|| anyhow::anyhow!("Failed to get page {}", page_index))?; + + let (page_width, page_height) = page.get_size(); + + let (width, height) = if rotation == 90 || rotation == 270 { + (page_height, page_width) + } else { + (page_width, page_height) + }; + + let scaled_width = (width * scale) as i32; + 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 != 0 { + let center_x = width / 2.0; + let center_y = height / 2.0; + context.translate(center_x, center_y); + context.rotate(f64::from(rotation) * std::f64::consts::PI / 180.0); + context.translate(-page_width / 2.0, -page_height / 2.0); + } + + page.render(&context); + + drop(context); + surface.flush(); + + let mut png_data: Vec = Vec::new(); + surface + .write_to_png(&mut png_data) + .map_err(|e| anyhow::anyhow!("Failed to write PNG: {}", e))?; + + let image = ImageReader::new(Cursor::new(png_data)) + .with_guessed_format() + .map_err(|e| anyhow::anyhow!("Failed to read PNG format: {}", e))? + .decode() + .map_err(|e| anyhow::anyhow!("Failed to decode PNG: {}", e))?; + + Ok(image) + } + + /// Re-render the current page. + fn rerender(&mut self) { + match Self::render_page(&self.document, self.current_page, self.rotation) { + Ok(rendered) => { + self.rendered = rendered; + self.refresh_handle(); + } + Err(e) => { + log::error!("Failed to render PDF page: {}", e); + } + } } /// Rebuild the handle after mutating `rendered`. pub fn refresh_handle(&mut self) { - self.handle = Self::build_handle(&self.rendered); + self.handle = super::create_image_handle(&self.rendered); } /// Returns the dimensions of the currently rendered page. pub fn dimensions(&self) -> (u32, u32) { - self.rendered.dimensions() + (self.rendered.width(), self.rendered.height()) } - /// Re-render the current page with the current rotation. - pub fn rerender_page(&mut self) { - // TODO: use PDF backend and self.rotation / self.current_page - // self.rendered = render_page_to_dynamic(...); - // self.refresh_handle(); + /// Navigate to a specific page. + pub fn goto_page(&mut self, page: u32) -> anyhow::Result<()> { + if page >= self.page_count { + return Err(anyhow::anyhow!( + "Page {} out of range (0-{})", + page, + self.page_count - 1 + )); + } + self.current_page = page; + self.rerender(); + Ok(()) } + + /// Navigate to the next page. + pub fn next_page(&mut self) -> bool { + if self.current_page + 1 < self.page_count { + self.current_page += 1; + self.rerender(); + true + } else { + false + } + } + + /// Navigate to the previous page. + pub fn prev_page(&mut self) -> bool { + if self.current_page > 0 { + self.current_page -= 1; + self.rerender(); + true + } else { + false + } + } + + /// Rotate 90 degrees clockwise. + pub fn rotate_cw(&mut self) { + self.rotation = (self.rotation + ROTATION_STEP).rem_euclid(FULL_ROTATION); + self.rerender(); + } + + /// Rotate 90 degrees counter-clockwise. + pub fn rotate_ccw(&mut self) { + self.rotation = (self.rotation - ROTATION_STEP).rem_euclid(FULL_ROTATION); + self.rerender(); + } + + /// Flip horizontally. + pub fn flip_horizontal(&mut self) { + self.rendered = DynamicImage::ImageRgba8(imageops::flip_horizontal(&self.rendered)); + self.refresh_handle(); + } + + /// Flip vertically. + pub fn flip_vertical(&mut self) { + self.rendered = DynamicImage::ImageRgba8(imageops::flip_vertical(&self.rendered)); + self.refresh_handle(); + } + /// Extract metadata for this portable document. - pub fn extract_meta(&self) -> super::meta::DocumentMeta { + pub fn extract_meta(&self, path: &Path) -> super::meta::DocumentMeta { let (width, height) = self.dimensions(); + super::meta::build_portable_meta(path, width, height, self.page_count) + } - super::meta::build_portable_meta(&self.path, width, height, self.page_count) + /// Get total page count. + pub fn page_count(&self) -> u32 { + self.page_count + } + + /// Get current page index (0-based). + pub fn current_page(&self) -> u32 { + self.current_page + } + + /// Get cached thumbnail handle for a specific page. + /// Returns None if thumbnails not yet generated. + pub fn get_thumbnail(&self, page: u32) -> Option { + self.thumbnail_cache + .as_ref() + .and_then(|cache| cache.get(page as usize).cloned()) } } diff --git a/src/app/document/raster.rs b/src/app/document/raster.rs index 119d359..32b7016 100644 --- a/src/app/document/raster.rs +++ b/src/app/document/raster.rs @@ -1,72 +1,71 @@ // SPDX-License-Identifier: GPL-3.0-or-later // src/app/document/raster.rs -use std::path::PathBuf; +use std::path::Path; -use cosmic::iced::widget::image as iced_image; -use image::{GenericImageView, DynamicImage, ImageReader}; +use image::{imageops, DynamicImage, GenericImageView, ImageReader}; + +use super::ImageHandle; /// Represents a raster image document (PNG, JPEG, WebP, ...). pub struct RasterDocument { - pub path: Option, - pub image: DynamicImage, - pub handle: iced_image::Handle, + /// The decoded image document. + document: DynamicImage, + /// Cached handle for rendering. + pub handle: ImageHandle, } impl RasterDocument { /// Load a raster document from disk. - pub fn open(path: PathBuf) -> image::ImageResult { - let img = ImageReader::open(&path)?.decode()?; - let handle = Self::build_handle(&img); + pub fn open(path: &Path) -> image::ImageResult { + let document = ImageReader::open(path)?.decode()?; + let handle = super::create_image_handle(&document); - Ok(Self { - path: Some(path), - image: img, - handle, - }) + Ok(Self { document, handle }) } - /// Construct a handle from a DynamicImage. - fn build_handle(img: &DynamicImage) -> iced_image::Handle { - // Get image dimensions. - let (w, h) = img.dimensions(); - - // Convert to RGBA8 buffer and extract raw bytes. - let rgba = img.to_rgba8(); - let pixels = rgba.into_raw(); // Vec - - // Build an iced image handle from raw RGBA pixels. - iced_image::Handle::from_rgba(w, h, pixels) - } - - /// Rebuild the handle after mutating `image`. + /// Rebuild the handle after mutating `document`. pub fn refresh_handle(&mut self) { - self.handle = Self::build_handle(&self.image); + self.handle = super::create_image_handle(&self.document); } /// Returns the native pixel dimensions (width, height). pub fn dimensions(&self) -> (u32, u32) { - self.image.dimensions() + self.document.dimensions() } - /// Save the current image back to disk (overwrite). - pub fn save(&self) -> image::ImageResult<()> { - if let Some(path) = &self.path { - self.image.save(path) - } else { - // Cant imagine that it happen but caller should handle missing path case. - Err(image::ImageError::Parameter( - image::error::ParameterError::from_kind(image::error::ParameterErrorKind::Generic( - "RasterDocument does not have a path".into(), - )), - )) - } + /// Save the current document to disk. + pub fn save(&self, path: &Path) -> image::ImageResult<()> { + self.document.save(path) } + /// Extract metadata for this raster document. - pub fn extract_meta(&self) -> super::meta::DocumentMeta { - let path = self.path.as_deref().unwrap_or(std::path::Path::new("")); + pub fn extract_meta(&self, path: &Path) -> super::meta::DocumentMeta { let (width, height) = self.dimensions(); + super::meta::build_raster_meta(path, &self.document, width, height) + } - super::meta::build_raster_meta(path, &self.image, width, height) + /// Rotate 90 degrees clockwise. + pub fn rotate_cw(&mut self) { + self.document = DynamicImage::ImageRgba8(imageops::rotate90(&self.document)); + self.refresh_handle(); + } + + /// Rotate 90 degrees counter-clockwise. + pub fn rotate_ccw(&mut self) { + self.document = DynamicImage::ImageRgba8(imageops::rotate270(&self.document)); + self.refresh_handle(); + } + + /// Flip horizontally. + pub fn flip_horizontal(&mut self) { + self.document = DynamicImage::ImageRgba8(imageops::flip_horizontal(&self.document)); + self.refresh_handle(); + } + + /// Flip vertically. + pub fn flip_vertical(&mut self) { + self.document = DynamicImage::ImageRgba8(imageops::flip_vertical(&self.document)); + self.refresh_handle(); } } diff --git a/src/app/document/transform.rs b/src/app/document/transform.rs deleted file mode 100644 index 2b410c0..0000000 --- a/src/app/document/transform.rs +++ /dev/null @@ -1,112 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// src/app/document/transform.rs -// -// High-level document transformations (rotate, flip, etc.). - -use image::{imageops, DynamicImage}; - -use super::portable::PortableDocument; -use super::raster::RasterDocument; -use super::vector::VectorDocument; -use super::DocumentContent; - -/// Rotate current document 90 degrees clockwise. -pub fn rotate_cw(doc: &mut DocumentContent) { - match doc { - DocumentContent::Raster(raster) => rotate_cw_raster(raster), - DocumentContent::Vector(vector) => rotate_cw_vector(vector), - DocumentContent::Portable(portable) => rotate_cw_portable(portable), - } -} - -/// Rotate current document 90 degrees counter-clockwise. -pub fn rotate_ccw(doc: &mut DocumentContent) { - match doc { - DocumentContent::Raster(raster) => rotate_ccw_raster(raster), - DocumentContent::Vector(vector) => rotate_ccw_vector(vector), - DocumentContent::Portable(portable) => rotate_ccw_portable(portable), - } -} - -/// Flip current document horizontally. -pub fn flip_horizontal(doc: &mut DocumentContent) { - match doc { - DocumentContent::Raster(raster) => flip_horizontal_raster(raster), - DocumentContent::Vector(vector) => flip_horizontal_vector(vector), - DocumentContent::Portable(portable) => flip_horizontal_portable(portable), - } -} - -/// Flip current document vertically. -pub fn flip_vertical(doc: &mut DocumentContent) { - match doc { - DocumentContent::Raster(raster) => flip_vertical_raster(raster), - DocumentContent::Vector(vector) => flip_vertical_vector(vector), - DocumentContent::Portable(portable) => flip_vertical_portable(portable), - } -} - -// --- Raster implementations --------------------------------------------------- - -fn rotate_cw_raster(doc: &mut RasterDocument) { - doc.image = DynamicImage::ImageRgba8(imageops::rotate90(&doc.image)); - doc.refresh_handle(); -} - -fn rotate_ccw_raster(doc: &mut RasterDocument) { - doc.image = DynamicImage::ImageRgba8(imageops::rotate270(&doc.image)); - doc.refresh_handle(); -} - -fn flip_horizontal_raster(doc: &mut RasterDocument) { - doc.image = DynamicImage::ImageRgba8(imageops::flip_horizontal(&doc.image)); - doc.refresh_handle(); -} - -fn flip_vertical_raster(doc: &mut RasterDocument) { - doc.image = DynamicImage::ImageRgba8(imageops::flip_vertical(&doc.image)); - doc.refresh_handle(); -} - -// --- Portable implementations (operate on rendered image) --------------------- - -fn rotate_cw_portable(doc: &mut PortableDocument) { - // Keep rotation in sync for a future real PDF backend. - doc.rotation = (doc.rotation + 90).rem_euclid(360); - doc.rendered = DynamicImage::ImageRgba8(imageops::rotate90(&doc.rendered)); - doc.refresh_handle(); -} - -fn rotate_ccw_portable(doc: &mut PortableDocument) { - doc.rotation = (doc.rotation - 90).rem_euclid(360); - doc.rendered = DynamicImage::ImageRgba8(imageops::rotate270(&doc.rendered)); - doc.refresh_handle(); -} - -fn flip_horizontal_portable(doc: &mut PortableDocument) { - doc.rendered = DynamicImage::ImageRgba8(imageops::flip_horizontal(&doc.rendered)); - doc.refresh_handle(); -} - -fn flip_vertical_portable(doc: &mut PortableDocument) { - doc.rendered = DynamicImage::ImageRgba8(imageops::flip_vertical(&doc.rendered)); - doc.refresh_handle(); -} - -// --- Vector implementations (view-transform only, for now) -------------------- - -fn rotate_cw_vector(_doc: &mut VectorDocument) { - // TODO: either update a rotation property or re-rasterize with rotation. -} - -fn rotate_ccw_vector(_doc: &mut VectorDocument) { - // TODO: either update a rotation property or re-rasterize with rotation. -} - -fn flip_horizontal_vector(_doc: &mut VectorDocument) { - // TODO: apply horizontal flip to SVG or adjust view transform. -} - -fn flip_vertical_vector(_doc: &mut VectorDocument) { - // TODO: apply vertical flip to SVG or adjust view transform. -} diff --git a/src/app/document/utils.rs b/src/app/document/utils.rs index 63f546a..3118265 100644 --- a/src/app/document/utils.rs +++ b/src/app/document/utils.rs @@ -3,19 +3,71 @@ // // Utility functions for document operations. +use std::path::Path; + /// Set an image as desktop wallpaper using multiple fallback methods. /// -/// Expects an absolute path as string. -pub fn set_as_wallpaper(path_str: &str) { +/// 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 path_str = match abs_path.to_str() { + Some(s) => s, + None => { + 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 let Some(home) = dirs::home_dir() { - let cosmic_config = home.join(".config/cosmic/com.system76.CosmicBackground/v1/all"); + // Method 1: Try COSMIC Desktop (direct config file modification). + if try_cosmic_wallpaper(path_str) { + return; + } - if cosmic_config.exists() { - let config_content = format!( - r#"( + // 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("{}"), filter_by_theme: true, @@ -24,86 +76,91 @@ pub fn set_as_wallpaper(path_str: &str) { scaling_mode: Zoom, sampling_method: Alphanumeric, )"#, - path_str - ); + path_str + ); - match std::fs::write(&cosmic_config, config_content) { - Ok(_) => { - log::info!("✓ Wallpaper set via COSMIC config file"); - return; - } - Err(e) => { - log::warn!("Failed to write COSMIC config: {}", e); - } - } + 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 } } +} - // Method 2: Try wallpaper crate (supports KDE, XFCE, Windows, macOS) +/// 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 successfully via wallpaper crate"); - return; + log::info!("Wallpaper set via wallpaper crate"); + true } Err(e) => { log::warn!("wallpaper crate failed: {}", e); + false } } +} - // Method 3: Try GNOME via gsettings +/// Try setting wallpaper via GNOME gsettings. +fn try_gsettings_wallpaper(path_str: &str) -> bool { let uri = format!("file://{}", path_str); - log::info!("Trying gsettings with URI: {}", uri); - match std::process::Command::new("gsettings") - .args(&[ - "set", - "org.gnome.desktop.background", - "picture-uri", - &uri, - ]) + let output = match std::process::Command::new("gsettings") + .args(["set", "org.gnome.desktop.background", "picture-uri", &uri]) .output() { - Ok(output) if output.status.success() => { - log::info!("✓ Wallpaper set via gsettings (light mode)"); - - // Also set dark mode wallpaper - let _ = std::process::Command::new("gsettings") - .args(&[ - "set", - "org.gnome.desktop.background", - "picture-uri-dark", - &uri, - ]) - .output(); - return; - } - Ok(output) => { - log::warn!( - "gsettings failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } + 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; } - // Method 4: Try feh (common on tiling WMs like i3, sway) - match std::process::Command::new("feh") - .args(&["--bg-scale", path_str]) + 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 output = match std::process::Command::new("feh") + .args(["--bg-scale", path_str]) .output() { - Ok(output) if output.status.success() => { - log::info!("✓ Wallpaper set via feh"); - return; - } - Ok(_) => { - log::warn!("feh failed"); - } + Ok(o) => o, Err(_) => { log::warn!("feh not available"); + return false; } - } + }; - log::error!("✗ All methods failed to set wallpaper"); + if output.status.success() { + log::info!("Wallpaper set via feh"); + true + } else { + log::warn!("feh failed"); + false + } } diff --git a/src/app/document/vector.rs b/src/app/document/vector.rs index f093720..0d97992 100644 --- a/src/app/document/vector.rs +++ b/src/app/document/vector.rs @@ -3,33 +3,76 @@ // // Vector documents (SVG, etc.). -use std::path::PathBuf; +use std::path::Path; -use cosmic::iced::widget::image as iced_image; +use image::{imageops, DynamicImage, RgbaImage}; +use resvg::tiny_skia::{self, Pixmap}; +use resvg::usvg::{Options, Tree}; + +use super::ImageHandle; +use crate::constant::{FULL_ROTATION, MIN_PIXMAP_SIZE, ROTATION_STEP}; + +/// Accumulated transformations for a vector document. +#[derive(Debug, Clone, Copy, Default)] +pub struct VectorTransform { + /// Rotation in degrees (0, 90, 180, 270). + pub rotation: i16, + /// Horizontal flip. + pub flip_h: bool, + /// Vertical flip. + pub flip_v: bool, +} /// Represents a vector document such as SVG. -/// For now this only stores the raw data and a rasterized handle. pub struct VectorDocument { - pub path: PathBuf, - pub raw_data: String, - pub handle: iced_image::Handle, - /// Cached dimensions of the rasterized representation. + /// 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: f32, + /// Accumulated transformations. + transform: VectorTransform, + /// 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 { - pub fn open(path: PathBuf) -> anyhow::Result { - let raw_data = std::fs::read_to_string(&path)?; + /// Load a vector document from disk. + pub fn open(path: &Path) -> anyhow::Result { + let raw_data = std::fs::read_to_string(path)?; - // TODO: proper SVG parsing and rendering. - // For now, use a placeholder size based on a typical default. - let (width, height) = (800, 600); - let handle = iced_image::Handle::from_rgba(1, 1, vec![0, 0, 0, 0]); + // 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 = VectorTransform::default(); + + // Render at native scale (1.0). + let (rendered, width, height) = + render_document(&document, native_width, native_height, 1.0, &transform)?; + let handle = super::create_image_handle(&rendered); Ok(Self { - path, - raw_data, + document, + native_width, + native_height, + current_scale: 1.0, + transform, + rendered, handle, width, height, @@ -41,14 +84,148 @@ impl VectorDocument { (self.width, self.height) } - pub fn refresh_handle(&mut self) { - // TODO: re-render SVG to DynamicImage and rebuild handle. - // Update self.width and self.height accordingly. - } - /// Extract metadata for this vector document. - pub fn extract_meta(&self) -> super::meta::DocumentMeta { - let (width, height) = self.dimensions(); + /// Re-render the SVG at a new scale, preserving transformations. + /// Returns true if re-rendering occurred. + pub fn render_at_scale(&mut self, scale: f32) -> bool { + // Skip if scale hasn't changed + if (self.current_scale - scale).abs() < f32::EPSILON { + return false; + } - super::meta::build_vector_meta(&self.path, width, height) + 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 = super::create_image_handle(&self.rendered); + true + } + Err(e) => { + log::error!("Failed to re-render SVG at scale {}: {}", scale, e); + false + } + } + } + + /// Rotate 90 degrees clockwise. + pub fn rotate_cw(&mut self) { + self.transform.rotation = + (self.transform.rotation + ROTATION_STEP).rem_euclid(FULL_ROTATION); + self.rerender(); + } + + /// Rotate 90 degrees counter-clockwise. + pub fn rotate_ccw(&mut self) { + self.transform.rotation = + (self.transform.rotation - ROTATION_STEP).rem_euclid(FULL_ROTATION); + self.rerender(); + } + + /// Flip horizontally. + pub fn flip_horizontal(&mut self) { + self.transform.flip_h = !self.transform.flip_h; + self.rerender(); + } + + /// Flip vertically. + pub fn flip_vertical(&mut self) { + self.transform.flip_v = !self.transform.flip_v; + self.rerender(); + } + + /// 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 = super::create_image_handle(&self.rendered); + } + } + + /// Extract metadata for this vector document. + pub fn extract_meta(&self, path: &Path) -> super::meta::DocumentMeta { + // Report native dimensions in metadata. + super::meta::build_vector_meta(path, self.native_width, self.native_height) } } + +/// Render the SVG document at a given scale with transformations. +fn render_document( + document: &Tree, + native_width: u32, + native_height: u32, + scale: f32, + transform: &VectorTransform, +) -> anyhow::Result<(DynamicImage, u32, u32)> { + let width = (((native_width as f32) * scale).ceil() as u32).max(MIN_PIXMAP_SIZE); + let height = (((native_height as f32) * scale).ceil() as u32).max(MIN_PIXMAP_SIZE); + + let mut pixmap = + Pixmap::new(width, height).ok_or_else(|| anyhow::anyhow!("Failed to create pixmap"))?; + + let ts = tiny_skia::Transform::from_scale(scale, scale); + resvg::render(document, ts, &mut pixmap.as_mut()); + + let mut image = pixmap_to_dynamic_image(&pixmap); + + // Apply flip transformations + if transform.flip_h { + image = DynamicImage::ImageRgba8(imageops::flip_horizontal(&image)); + } + if transform.flip_v { + image = DynamicImage::ImageRgba8(imageops::flip_vertical(&image)); + } + + // Apply rotation + image = match transform.rotation { + 90 => DynamicImage::ImageRgba8(imageops::rotate90(&image)), + 180 => DynamicImage::ImageRgba8(imageops::rotate180(&image)), + 270 => DynamicImage::ImageRgba8(imageops::rotate270(&image)), + _ => 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 = (pixel.red() as u16 * 255 / a as u16) as u8; + let g = (pixel.green() as u16 * 255 / a as u16) as u8; + let b = (pixel.blue() as u16 * 255 / a as u16) 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) +} diff --git a/src/app/message.rs b/src/app/message.rs index b020cda..3ee69ea 100644 --- a/src/app/message.rs +++ b/src/app/message.rs @@ -1,88 +1,71 @@ // SPDX-License-Identifier: GPL-3.0-or-later // src/app/message.rs // -// All application messages (events, user actions, signals). +// Application messages: events, user actions, and internal signals. use std::path::PathBuf; use crate::app::ContextPage; -/// Messages emitted by user actions, async I/O, or internal signals. #[derive(Debug, Clone)] pub enum AppMessage { - // === File / Navigation === - /// Open a file at the given path. + // File / navigation. #[allow(dead_code)] OpenPath(PathBuf), - /// Navigate to the next document in folder. NextDocument, - /// Navigate to the previous document in folder. PrevDocument, + GotoPage(u32), + GenerateThumbnailPage(u32), - // === Transformations === - /// Rotate 90° clockwise. + // Transformations. RotateCW, - /// Rotate 90° counter-clockwise. RotateCCW, - /// Flip horizontally (mirror). FlipHorizontal, - /// Flip vertically. FlipVertical, - // === Zoom === - /// Zoom in by a fixed step. + // View / zoom. ZoomIn, - /// Zoom out by a fixed step. ZoomOut, - /// Reset zoom to 100%. ZoomReset, - /// Fit document to window. ZoomFit, - /// Update zoom and pan from viewer (mouse interaction). - ViewerStateChanged { scale: f32, offset_x: f32, offset_y: f32 }, + ViewerStateChanged { + scale: f32, + offset_x: f32, + offset_y: f32, + }, - // === Pan === - /// Pan image left. + // Pan control. PanLeft, - /// Pan image right. PanRight, - /// Pan image up. PanUp, - /// Pan image down. PanDown, - /// Reset pan to center. PanReset, - // === Tool Modes === - /// Toggle crop mode. + // Tool modes. ToggleCropMode, - /// Toggle scale mode. ToggleScaleMode, - // === Panels (COSMIC-managed) === - /// Toggle a context drawer page. + // Panels. ToggleContextPage(ContextPage), - /// Toggle the nav bar (left panel) visibility. ToggleNavBar, - // === Metadata === - /// Refresh metadata from the current document. + // Metadata. #[allow(dead_code)] RefreshMetadata, - // === Wallpaper === - /// Set current image as wallpaper. + // Wallpaper. SetAsWallpaper, - // === Errors === - /// Display an error message. + // Errors. #[allow(dead_code)] ShowError(String), - /// Clear the current error. #[allow(dead_code)] ClearError, - /// Fallback for unhandled or no-op cases. + // UI refresh. + RefreshView, + + // Fallback. #[allow(dead_code)] NoOp, } diff --git a/src/app/mod.rs b/src/app/mod.rs index b9d2c42..42eb9f7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -10,9 +10,12 @@ pub mod update; mod view; +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; @@ -91,13 +94,16 @@ impl cosmic::Application for Noctua { document::file::open_initial_path(&mut model, path); } - // Initialize empty nav bar (for folder/thumbnail navigation later). + // 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; 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, @@ -107,7 +113,7 @@ impl cosmic::Application for Noctua { config, config_handler, }, - Task::none(), + init_task, ) } @@ -117,15 +123,18 @@ impl cosmic::Application for Noctua { fn update(&mut self, message: Self::Message) -> Task> { match &message { - // Handle nav bar toggle. I think this is ugly but it works. AppMessage::ToggleNavBar => { - self.config.nav_bar_visible = !self.config.nav_bar_visible; - self.core.nav_bar_set_toggled(self.config.nav_bar_visible); + 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 { + return start_thumbnail_generation_task(&self.model); + } return Task::none(); } - // Handle context panel toggle. AppMessage::ToggleContextPage(page) => { if self.context_page == *page { self.core.window.show_context = !self.core.window.show_context; @@ -138,11 +147,22 @@ impl cosmic::Application for Noctua { return Task::none(); } + AppMessage::OpenPath(_) | AppMessage::NextDocument | AppMessage::PrevDocument => { + let result = update::update(&mut self.model, &message, &self.config); + 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]), + }; + } + _ => {} } - update::update(&mut self.model, message); - Task::none() + match update::update(&mut self.model, &message, &self.config) { + update::UpdateResult::None => Task::none(), + update::UpdateResult::Task(task) => task, + } } fn header_start(&self) -> Vec> { @@ -154,7 +174,7 @@ impl cosmic::Application for Noctua { } fn view(&self) -> Element<'_, Self::Message> { - view::view(&self.model) + view::view(&self.model, &self.config) } fn context_drawer(&self) -> Option> { @@ -171,12 +191,22 @@ impl cosmic::Application for Noctua { Some(&self.nav) } + fn nav_bar(&self) -> Option>> { + if !self.core.nav_bar_active() { + return None; + } + view::nav_bar(&self.model) + } + fn footer(&self) -> Option> { Some(view::footer::view(&self.model)) } fn subscription(&self) -> Subscription { - keyboard::on_key_press(handle_key_press) + Subscription::batch([ + keyboard::on_key_press(handle_key_press), + thumbnail_refresh_subscription(self), + ]) } } @@ -226,7 +256,7 @@ fn handle_key_press(key: Key, modifiers: Modifiers) -> Option { } // Zoom. - Key::Character("+" |"=") => Some(ZoomIn), + 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), @@ -250,3 +280,38 @@ fn handle_key_press(key: Key, modifiers: Modifiers) -> Option { _ => None, } } + +// ============================================================================= +// Thumbnail Helpers +// ============================================================================= + +fn start_thumbnail_generation(model: &AppModel) -> Task> { + start_thumbnail_generation_task(model) +} + +fn start_thumbnail_generation_task(model: &AppModel) -> Task> { + if let Some(doc) = &model.document { + let page_count = doc.page_count().unwrap_or(0); + 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: &Noctua) -> Subscription { + let needs_refresh = app + .model + .document + .as_ref() + .map_or(false, |doc| doc.is_multi_page() && !doc.thumbnails_ready()); + + if needs_refresh { + time::every(Duration::from_millis(100)).map(|_| AppMessage::RefreshView) + } else { + Subscription::none() + } +} diff --git a/src/app/model.rs b/src/app/model.rs index 0466502..271694c 100644 --- a/src/app/model.rs +++ b/src/app/model.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // src/app/model.rs // -// Global application state. +// Application state. use std::path::PathBuf; @@ -9,20 +9,18 @@ use crate::app::document::meta::DocumentMeta; use crate::app::document::DocumentContent; use crate::config::AppConfig; -/// How the document is currently fitted into the window. +// ============================================================================= +// Enums +// ============================================================================= + #[derive(Debug, Clone, Copy)] pub enum ViewMode { - /// Fit document to available window size. Fit, - /// Display at 100% (1.0 scale). ActualSize, - /// Custom zoom factor (e.g., 0.5 = 50%, 2.0 = 200%). Custom(f32), } impl ViewMode { - /// Return the effective zoom factor for this mode. - /// For `Fit`, returns `None` since the factor depends on window size. pub fn zoom_factor(&self) -> Option { match self { ViewMode::Fit => None, @@ -32,7 +30,6 @@ impl ViewMode { } } -/// Current editing / interaction mode. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToolMode { None, @@ -40,44 +37,34 @@ pub enum ToolMode { Scale, } -/// Pan step size in pixels per key press. -pub const PAN_STEP: f32 = 50.0; +// ============================================================================= +// Model +// ============================================================================= -/// Global application state. -#[derive(Debug)] pub struct AppModel { - /// Currently opened document (raster/vector/portable). + // Document. pub document: Option, - - /// Cached metadata for the current document. - /// Loaded lazily when the metadata panel is opened. pub metadata: Option, - - /// Path of the currently opened document, if any. pub current_path: Option, - /// List of files in the current folder for navigation. + // Navigation. pub folder_entries: Vec, - - /// Index into `folder_entries` of the current file. pub current_index: Option, - /// View / zoom state. + // View. pub view_mode: ViewMode, - - /// Pan offset (in pixels, relative to centered position). pub pan_x: f32, pub pan_y: f32, - /// Current tool mode. + // Tools. pub tool_mode: ToolMode, - /// Last error message to be shown in the UI, if any. + // UI state. pub error: Option, + pub tick: u64, } impl AppModel { - /// Construct a new application state from configuration. pub fn new(_config: AppConfig) -> Self { Self { document: None, @@ -90,26 +77,23 @@ impl AppModel { pan_y: 0.0, tool_mode: ToolMode::None, error: None, + tick: 0, } } - /// Helper: set an error string. pub fn set_error>(&mut self, msg: S) { self.error = Some(msg.into()); } - /// Helper: clear current error. pub fn clear_error(&mut self) { self.error = None; } - /// Reset pan offset to center. pub fn reset_pan(&mut self) { self.pan_x = 0.0; self.pan_y = 0.0; } - /// Get the current zoom factor, if applicable. pub fn zoom_factor(&self) -> Option { self.view_mode.zoom_factor() } diff --git a/src/app/update.rs b/src/app/update.rs index 1201683..ba8542a 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -3,19 +3,31 @@ // // Application update loop: applies messages to the global model state. +use cosmic::{Action, Task}; + use super::document; use super::message::AppMessage; -use super::model::{AppModel, ToolMode, ViewMode, PAN_STEP}; +use super::model::{AppModel, ToolMode, ViewMode}; +use crate::config::AppConfig; -/// Central update function applying messages to the model. -/// -/// Panel toggle messages (ToggleContextPage) are handled directly in -/// `Noctua::update()` since they affect COSMIC's Core state. -pub fn update(model: &mut AppModel, msg: AppMessage) { +// ============================================================================= +// Update Result +// ============================================================================= + +pub enum UpdateResult { + None, + Task(Task>), +} + +// ============================================================================= +// Main Update Function +// ============================================================================= + +pub fn update(model: &mut AppModel, msg: &AppMessage, config: &AppConfig) -> UpdateResult { match msg { - // ===== File / navigation ========================================================== + // ---- File / navigation ---------------------------------------------------- AppMessage::OpenPath(path) => { - document::file::open_single_file(model, &path); + document::file::open_single_file(model, path); } AppMessage::NextDocument => { @@ -26,42 +38,79 @@ pub fn update(model: &mut AppModel, msg: AppMessage) { document::file::navigate_prev(model); } - // ===== View / zoom =============================================================== - AppMessage::ZoomIn => zoom_in(model), - AppMessage::ZoomOut => zoom_out(model), + AppMessage::GotoPage(page) => { + if let Some(doc) = &mut model.document { + if let Err(e) = doc.goto_page(*page) { + log::error!("Failed to navigate to page {}: {}", page, e); + } + } + } + + // ---- Thumbnail generation ------------------------------------------------- + AppMessage::GenerateThumbnailPage(page) => { + if let Some(doc) = &mut model.document { + if let Some(next_page) = doc.generate_thumbnail_page(*page) { + return UpdateResult::Task(Task::batch([ + Task::future(async move { + Action::App(AppMessage::GenerateThumbnailPage(next_page)) + }), + Task::done(Action::App(AppMessage::RefreshView)), + ])); + } + } + } + + AppMessage::RefreshView => { + model.tick += 1; + } + + // ---- View / zoom --------------------------------------------------------- + AppMessage::ZoomIn => { + zoom_in(model, config); + } + + AppMessage::ZoomOut => { + zoom_out(model, config); + } + AppMessage::ZoomReset => { model.view_mode = ViewMode::ActualSize; model.reset_pan(); } + AppMessage::ZoomFit => { model.view_mode = ViewMode::Fit; model.reset_pan(); } - AppMessage::ViewerStateChanged { scale, offset_x, offset_y } => { - // Update model state from viewer (mouse interaction) - model.view_mode = ViewMode::Custom(scale); - model.pan_x = offset_x; - model.pan_y = offset_y; + + AppMessage::ViewerStateChanged { + scale, + offset_x, + offset_y, + } => { + model.view_mode = ViewMode::Custom(*scale); + model.pan_x = *offset_x; + model.pan_y = *offset_y; } - // ===== Pan control (Ctrl + arrow keys) =========================================== + // ---- Pan control --------------------------------------------------------- AppMessage::PanLeft => { - model.pan_x -= PAN_STEP; + model.pan_x -= config.pan_step; } AppMessage::PanRight => { - model.pan_x += PAN_STEP; + model.pan_x += config.pan_step; } AppMessage::PanUp => { - model.pan_y -= PAN_STEP; + model.pan_y -= config.pan_step; } AppMessage::PanDown => { - model.pan_y += PAN_STEP; + model.pan_y += config.pan_step; } AppMessage::PanReset => { model.reset_pan(); } - // ===== Tool modes ================================================================ + // ---- Tool modes ---------------------------------------------------------- AppMessage::ToggleCropMode => { model.tool_mode = if model.tool_mode == ToolMode::Crop { ToolMode::None @@ -77,100 +126,95 @@ pub fn update(model: &mut AppModel, msg: AppMessage) { }; } - // ===== Document transformations ================================================== + // ---- Document transformations -------------------------------------------- AppMessage::FlipHorizontal => { if let Some(doc) = &mut model.document { - document::transform::flip_horizontal(doc); + doc.flip_horizontal(); } } AppMessage::FlipVertical => { if let Some(doc) = &mut model.document { - document::transform::flip_vertical(doc); + doc.flip_vertical(); } } AppMessage::RotateCW => { if let Some(doc) = &mut model.document { - document::transform::rotate_cw(doc); + doc.rotate_cw(); } } AppMessage::RotateCCW => { if let Some(doc) = &mut model.document { - document::transform::rotate_ccw(doc); + doc.rotate_ccw(); } } - // ===== Metadata ================================================================== + // ---- Metadata ------------------------------------------------------------ AppMessage::RefreshMetadata => { refresh_metadata(model); } - // ===== Wallpaper ================================================================= + // ---- Wallpaper ----------------------------------------------------------- AppMessage::SetAsWallpaper => { set_as_wallpaper(model); } - // ===== Error handling ============================================================ + // ---- Error handling ------------------------------------------------------ AppMessage::ShowError(msg) => { - model.set_error(msg); + model.set_error(msg.clone()); } AppMessage::ClearError => { model.clear_error(); } - // ===== Handled elsewhere ========================================================= - AppMessage::ToggleContextPage(_) => { - // Handled in Noctua::update() directly. - } + // ---- Handled elsewhere --------------------------------------------------- + AppMessage::ToggleContextPage(_) | AppMessage::ToggleNavBar => {} - AppMessage::ToggleNavBar => { - // Handled in Noctua::update() directly. - } - - AppMessage::NoOp => { - // Intentionally do nothing. - } + AppMessage::NoOp => {} } + + UpdateResult::None } -/// Increment zoom level by 10%. -fn zoom_in(model: &mut AppModel) { +// ============================================================================= +// View Helpers +// ============================================================================= + +fn zoom_in(model: &mut AppModel, config: &AppConfig) { let current = current_zoom(model); - let new_zoom = (current * 1.1).clamp(0.05, 20.0); + let new_zoom = (current * config.scale_step).clamp(config.min_scale, config.max_scale); + let factor = new_zoom / current; + model.pan_x *= factor; + model.pan_y *= factor; model.view_mode = ViewMode::Custom(new_zoom); } -/// Decrement zoom level by ~9% (inverse of 1.1). -fn zoom_out(model: &mut AppModel) { +fn zoom_out(model: &mut AppModel, config: &AppConfig) { let current = current_zoom(model); - let new_zoom = (current / 1.1).clamp(0.05, 20.0); + let new_zoom = (current / config.scale_step).clamp(config.min_scale, config.max_scale); + let factor = new_zoom / current; + model.pan_x *= factor; + model.pan_y *= factor; model.view_mode = ViewMode::Custom(new_zoom); } -/// Extract the current effective zoom factor from the view mode. fn current_zoom(model: &AppModel) -> f32 { match model.view_mode { - ViewMode::Fit => 1.0, - ViewMode::ActualSize => 1.0, + ViewMode::Fit | ViewMode::ActualSize => 1.0, ViewMode::Custom(z) => z, } } -/// Refresh metadata from the current document. fn refresh_metadata(model: &mut AppModel) { - model.metadata = model.document.as_ref().map(|doc| doc.extract_meta()); + model.metadata = match (&model.document, &model.current_path) { + (Some(doc), Some(path)) => Some(doc.extract_meta(path)), + _ => None, + }; } -/// Set the current image as desktop wallpaper. fn set_as_wallpaper(model: &mut AppModel) { let Some(path) = model.current_path.as_ref() else { model.set_error("No image loaded"); return; }; - - let path = path.clone(); - - // Spawn async task to set wallpaper - tokio::spawn(async move { - document::set_as_wallpaper(&path); - }); + document::set_as_wallpaper(path); } diff --git a/src/app/view/canvas.rs b/src/app/view/canvas.rs index 39f4e32..61f8c30 100644 --- a/src/app/view/canvas.rs +++ b/src/app/view/canvas.rs @@ -10,10 +10,11 @@ use cosmic::Element; use super::image_viewer::Viewer; use crate::app::model::ViewMode; use crate::app::{AppMessage, AppModel}; +use crate::config::AppConfig; use crate::fl; /// Render the center canvas area with the current document. -pub fn view(model: &AppModel) -> Element<'_, AppMessage> { +pub fn view<'a>(model: &'a AppModel, config: &'a AppConfig) -> Element<'a, AppMessage> { if let Some(doc) = &model.document { let handle = doc.handle(); @@ -25,21 +26,20 @@ pub fn view(model: &AppModel) -> Element<'_, AppMessage> { }; // Use our forked viewer with external state control + // scale_step is (scale_step - 1.0) because viewer uses additive step let img_viewer = Viewer::new(handle) .with_state(scale, model.pan_x, model.pan_y) - .on_state_change(|scale, offset_x, offset_y| { - AppMessage::ViewerStateChanged { - scale, - offset_x, - offset_y, - } + .on_state_change(|scale, offset_x, offset_y| AppMessage::ViewerStateChanged { + scale, + offset_x, + offset_y, }) .width(Length::Fill) .height(Length::Fill) .content_fit(content_fit) - .min_scale(0.1) - .max_scale(20.0) - .scale_step(0.1); + .min_scale(config.min_scale) + .max_scale(config.max_scale) + .scale_step(config.scale_step - 1.0); container(img_viewer) .width(Length::Fill) diff --git a/src/app/view/footer.rs b/src/app/view/footer.rs index 83beeb2..204d01e 100644 --- a/src/app/view/footer.rs +++ b/src/app/view/footer.rs @@ -9,17 +9,19 @@ use cosmic::Element; use crate::app::model::{AppModel, ViewMode}; use crate::app::AppMessage; +use crate::fl; /// Build the footer element with zoom controls and document info. pub fn view(model: &AppModel) -> Element<'_, AppMessage> { // Zoom level display. let zoom_text = match model.view_mode { - ViewMode::Fit => "Fit".to_string(), + ViewMode::Fit => fl!("status-zoom-fit"), _ => { if let Some(zoom) = model.zoom_factor() { - format!("{}%", (zoom * 100.0).round() as i32) + let percent = (zoom * 100.0).round() as i32; + fl!("status-zoom-percent", percent: percent) } else { - "Fit".to_string() + fl!("status-zoom-fit") } } }; @@ -27,7 +29,7 @@ pub fn view(model: &AppModel) -> Element<'_, AppMessage> { // Document dimensions (if available). let doc_info = if let Some(ref doc) = model.document { let (w, h) = doc.dimensions(); - format!("{}×{}", w, h) + fl!("status-doc-dimensions", width: w, height: h) } else { String::new() }; @@ -36,7 +38,7 @@ pub fn view(model: &AppModel) -> Element<'_, AppMessage> { let nav_info = if !model.folder_entries.is_empty() { let current = model.current_index.map(|i| i + 1).unwrap_or(0); let total = model.folder_entries.len(); - format!("{} / {}", current, total) + fl!("status-nav-position", current: current, total: total) } else { String::new() }; @@ -71,7 +73,7 @@ pub fn view(model: &AppModel) -> Element<'_, AppMessage> { .push(text::body(doc_info)) // Separator. .push_maybe(if !model.folder_entries.is_empty() { - Some(text::body(" | ")) + Some(text::body(fl!("status-separator"))) } else { None }) diff --git a/src/app/view/header.rs b/src/app/view/header.rs index ed47d8f..5970186 100644 --- a/src/app/view/header.rs +++ b/src/app/view/header.rs @@ -3,8 +3,8 @@ // // Header bar buttons (navigation, rotation, flip). -use cosmic::iced::Length; -use cosmic::widget::{button, horizontal_space, icon}; +use cosmic::iced::{Alignment, Length}; +use cosmic::widget::{button, horizontal_space, icon, row}; use cosmic::Element; use crate::app::message::AppMessage; @@ -15,38 +15,43 @@ use crate::app::ContextPage; pub fn header_start(model: &AppModel) -> Vec> { let has_doc = model.document.is_some(); + // Left: Nav toggle + Navigation + let left_controls = row() + .push( + button::icon(icon::from_name("go-previous-symbolic")) + .on_press_maybe(has_doc.then_some(AppMessage::PrevDocument)), + ) + .push( + button::icon(icon::from_name("go-next-symbolic")) + .on_press_maybe(has_doc.then_some(AppMessage::NextDocument)), + ); + + // Center: Transformations (horizontally centered) + let center_controls = row() + //.align_y(Alignment::Center) + .push( + button::icon(icon::from_name("object-rotate-left-symbolic")) + .on_press_maybe(has_doc.then_some(AppMessage::RotateCCW)), + ) + .push( + button::icon(icon::from_name("object-rotate-right-symbolic")) + .on_press_maybe(has_doc.then_some(AppMessage::RotateCW)), + ) + .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)), + ) + .push( + button::icon(icon::from_name("object-flip-vertical-symbolic")) + .on_press_maybe(has_doc.then_some(AppMessage::FlipVertical)), + ); + vec![ - // Nav bar toggle - button::icon(icon::from_name("view-sidebar-start-symbolic")) - .on_press(AppMessage::ToggleNavBar) - .into(), - // Spacer - horizontal_space().width(Length::Fixed(12.0)).into(), - // Navigation: previous / next - button::icon(icon::from_name("go-previous-symbolic")) - .on_press_maybe(has_doc.then_some(AppMessage::PrevDocument)) - .into(), - button::icon(icon::from_name("go-next-symbolic")) - .on_press_maybe(has_doc.then_some(AppMessage::NextDocument)) - .into(), - // Spacer - horizontal_space().width(Length::Fixed(12.0)).into(), - // Rotation: counter-clockwise / clockwise - button::icon(icon::from_name("object-rotate-left-symbolic")) - .on_press_maybe(has_doc.then_some(AppMessage::RotateCCW)) - .into(), - button::icon(icon::from_name("object-rotate-right-symbolic")) - .on_press_maybe(has_doc.then_some(AppMessage::RotateCW)) - .into(), - // Spacer - horizontal_space().width(Length::Fixed(12.0)).into(), - // Flip: horizontal / vertical - button::icon(icon::from_name("object-flip-horizontal-symbolic")) - .on_press_maybe(has_doc.then_some(AppMessage::FlipHorizontal)) - .into(), - button::icon(icon::from_name("object-flip-vertical-symbolic")) - .on_press_maybe(has_doc.then_some(AppMessage::FlipVertical)) - .into(), + left_controls.into(), + //horizontal_space().width(Length::Fill).into(), + center_controls.into(), + horizontal_space().width(Length::Fill).into(), ] } diff --git a/src/app/view/image_viewer.rs b/src/app/view/image_viewer.rs index ab34e7b..f647ef3 100644 --- a/src/app/view/image_viewer.rs +++ b/src/app/view/image_viewer.rs @@ -1,18 +1,21 @@ -//! Zoom and pan on an image. -//! Forked from cosmic::iced to support external state control. +// 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::widget::{tree::{self, Tree}, Widget}; -use cosmic::iced::advanced::{ - Clipboard, Layout, Shell, - layout, renderer, image as img_renderer, -}; +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::{self, FilterMethod}; -use cosmic::iced::{ - ContentFit, Element, Length, Pixels, Point, - Radians, Rectangle, Size, Vector, -}; +use cosmic::iced::widget::image::FilterMethod; +use cosmic::iced::{ContentFit, Element, Length, Pixels, Point, Radians, Rectangle, Size, Vector}; + +use crate::constant::{OFFSET_EPSILON, SCALE_EPSILON}; /// A frame that displays an image with the ability to zoom in/out and pan. #[allow(missing_debug_implementations)] @@ -26,14 +29,14 @@ pub struct Viewer { handle: Handle, filter_method: FilterMethod, content_fit: ContentFit, - /// Optional external state to override internal state - external_state: Option<(f32, Vector)>, // (scale, offset) + /// Optional external state to override internal state (scale, offset) + external_state: Option<(f32, Vector)>, /// Optional callback to notify state changes on_state_change: Option Message>>, } impl Viewer { - /// Creates a new [`Viewer`] with the given [`State`]. + /// Creates a new [`Viewer`] with the given handle. pub fn new>(handle: T) -> Self { Viewer { handle: handle.into(), @@ -67,7 +70,7 @@ impl Viewer { } /// Sets the [`FilterMethod`] of the [`Viewer`]. - pub fn filter_method(mut self, filter_method: image::FilterMethod) -> Self { + pub fn filter_method(mut self, filter_method: FilterMethod) -> Self { self.filter_method = filter_method; self } @@ -122,8 +125,7 @@ impl Viewer { } } -impl Widget - for Viewer +impl Widget for Viewer where Renderer: img_renderer::Renderer, Handle: Clone, @@ -135,7 +137,7 @@ where fn state(&self) -> tree::State { let mut state = State::new(); - // Apply external state if provided + // Apply external state if provided at creation if let Some((scale, offset)) = self.external_state { state.scale = scale; state.current_offset = offset; @@ -145,21 +147,21 @@ where } fn diff(&mut self, tree: &mut Tree) { - // Only update state if external state changed and user is not interacting - if let Some((scale, offset)) = self.external_state { + // 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::(); // Only apply external state if user is not currently dragging if !state.is_cursor_grabbed() { - // Check if external state differs from current state - let scale_changed = (state.scale - scale).abs() > 0.001; - let offset_changed = (state.current_offset.x - offset.x).abs() > 0.1 - || (state.current_offset.y - offset.y).abs() > 0.1; + // 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 = scale; - state.current_offset = offset; - state.starting_offset = offset; + state.scale = ext_scale; + state.current_offset = ext_offset; + state.starting_offset = ext_offset; } } } @@ -178,18 +180,12 @@ where renderer: &Renderer, limits: &layout::Limits, ) -> layout::Node { - // The raw w/h of the underlying image let image_size = renderer.measure_image(&self.handle); - let image_size = - Size::new(image_size.width as f32, image_size.height as f32); + let image_size = Size::new(image_size.width as f32, image_size.height as f32); - // The size to be available to the widget prior to `Shrink`ing let raw_size = limits.resolve(self.width, self.height, image_size); - - // The uncropped size of the image when fit to the bounds above let full_size = self.content_fit.fit(image_size, raw_size); - // Shrink the widget to fit the resized image, if requested let final_size = Size { width: match self.width { Length::Shrink => f32::min(raw_size.width, full_size.width), @@ -224,8 +220,7 @@ where }; match delta { - mouse::ScrollDelta::Lines { y, .. } - | mouse::ScrollDelta::Pixels { y, .. } => { + mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } => { let state = tree.state.downcast_mut::(); let previous_scale = state.scale; @@ -239,6 +234,22 @@ where }) .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, @@ -247,26 +258,8 @@ where self.content_fit, ); - let factor = state.scale / previous_scale - 1.0; - - let cursor_to_center = - cursor_position - bounds.center(); - - let adjustment = cursor_to_center * factor - + state.current_offset * factor; - - state.current_offset = Vector::new( - if scaled_size.width > bounds.width { - state.current_offset.x + adjustment.x - } else { - 0.0 - }, - if scaled_size.height > bounds.height { - state.current_offset.y + adjustment.y - } else { - 0.0 - }, - ); + state.current_offset = + clamp_offset(new_offset, bounds.size(), scaled_size); // Notify state change if let Some(ref on_change) = self.on_state_change { @@ -288,7 +281,6 @@ where }; let state = tree.state.downcast_mut::(); - state.cursor_grabbed_at = Some(cursor_position); state.starting_offset = state.current_offset; @@ -300,6 +292,15 @@ where 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 { + shell.publish(on_change( + state.scale, + state.current_offset.x, + state.current_offset.y, + )); + } + event::Status::Captured } else { event::Status::Ignored @@ -316,34 +317,18 @@ where bounds.size(), self.content_fit, ); - let hidden_width = (scaled_size.width - bounds.width / 2.0) - .max(0.0) - .round(); - - let hidden_height = (scaled_size.height - - bounds.height / 2.0) - .max(0.0) - .round(); let delta = position - origin; - let x = if bounds.width < scaled_size.width { - (state.starting_offset.x - delta.x) - .clamp(-hidden_width, hidden_width) - } else { - 0.0 - }; + // Pan: subtract delta from starting offset + let new_offset = Vector::new( + state.starting_offset.x - delta.x, + state.starting_offset.y - delta.y, + ); - let y = if bounds.height < scaled_size.height { - (state.starting_offset.y - delta.y) - .clamp(-hidden_height, hidden_height) - } else { - 0.0 - }; + state.current_offset = clamp_offset(new_offset, bounds.size(), scaled_size); - state.current_offset = Vector::new(x, y); - - // Notify state change on pan + // Notify state change during pan if let Some(ref on_change) = self.on_state_change { shell.publish(on_change( state.scale, @@ -395,7 +380,7 @@ where let state = tree.state.downcast_ref::(); let bounds = layout.bounds(); - let final_size = scaled_image_size( + let scaled_size = scaled_image_size( renderer, &self.handle, state, @@ -403,21 +388,23 @@ where self.content_fit, ); + // Calculate translation to center the image and apply offset let translation = { - let diff_w = bounds.width - final_size.width; - let diff_h = bounds.height - final_size.height; + // 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; - let image_top_left = match self.content_fit { - ContentFit::None => { - Vector::new(diff_w.max(0.0) / 2.0, diff_h.max(0.0) / 2.0) - } - _ => Vector::new(diff_w / 2.0, diff_h / 2.0), - }; + // 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); - image_top_left - state.offset(bounds, final_size) + // 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(), final_size); + let drawing_bounds = Rectangle::new(bounds.position(), scaled_size); let render = |renderer: &mut Renderer| { renderer.with_translation(translation, |renderer| { @@ -462,27 +449,31 @@ impl State { State::default() } - /// Returns the current offset of the [`State`], given the bounds - /// of the [`Viewer`] and its image. - fn offset(&self, bounds: Rectangle, image_size: Size) -> Vector { - let hidden_width = - (image_size.width - bounds.width / 2.0).max(0.0).round(); - - let hidden_height = - (image_size.height - bounds.height / 2.0).max(0.0).round(); - - Vector::new( - self.current_offset.x.clamp(-hidden_width, hidden_width), - self.current_offset.y.clamp(-hidden_height, hidden_height), - ) - } - /// 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> for Element<'a, Message, Theme, Renderer> where @@ -495,9 +486,7 @@ where } } -/// Returns the bounds of the underlying image, given the bounds of -/// the [`Viewer`]. Scaling will be applied and original aspect ratio -/// will be respected. +/// Returns the scaled size of the image given current state. pub fn scaled_image_size( renderer: &Renderer, handle: &::Handle, @@ -511,12 +500,9 @@ where let Size { width, height } = renderer.measure_image(handle); let image_size = Size::new(width as f32, height as f32); - // For ContentFit::None, use the raw image size directly with scale - // to ensure pixel-perfect rendering at scale 1.0 - let adjusted_fit = if matches!(content_fit, ContentFit::None) { - image_size - } else { - content_fit.fit(image_size, bounds) + let adjusted_fit = match content_fit { + ContentFit::None => image_size, + _ => content_fit.fit(image_size, bounds), }; Size::new( diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 2c7f35d..ba48d5e 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -7,13 +7,35 @@ mod canvas; pub mod footer; pub mod header; mod image_viewer; +pub mod panel_pages; pub mod panels; -use cosmic::Element; +use cosmic::iced::Length; +use cosmic::widget::container; +use cosmic::{Action, Element}; use crate::app::{AppMessage, AppModel}; +use crate::config::AppConfig; /// Main application view (canvas area). -pub fn view(model: &AppModel) -> Element<'_, AppMessage> { - canvas::view(model) +pub fn view<'a>(model: &'a AppModel, config: &'a AppConfig) -> Element<'a, AppMessage> { + canvas::view(model, config) +} + +/// Navigation bar content (left panel for multi-page documents). +/// +/// Returns None if no multi-page document is loaded. +pub fn nav_bar(model: &AppModel) -> Option>> { + let doc = model.document.as_ref()?; + if !doc.is_multi_page() { + return None; + } + + panel_pages::pages_panel(model).map(|panel| { + container(panel.map(Action::App)) + .width(Length::Shrink) + .height(Length::Fill) + .max_width(200) + .into() + }) } diff --git a/src/app/view/panel_pages.rs b/src/app/view/panel_pages.rs new file mode 100644 index 0000000..e207846 --- /dev/null +++ b/src/app/view/panel_pages.rs @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/app/view/panel_pages.rs +// +// Page thumbnail panel for multi-page documents (PDF, multi-page TIFF). + +use cosmic::iced::{Alignment, Length}; +use cosmic::widget::{button, column, scrollable, text}; +use cosmic::widget::image as cosmic_image; +use cosmic::Element; + +use crate::app::{AppMessage, AppModel}; +use crate::constant::THUMBNAIL_MAX_WIDTH; +use crate::fl; + +/// Content for the page navigation panel (COSMIC nav_bar). +/// Returns None if the current document doesn't support multiple pages. +pub fn pages_panel(model: &AppModel) -> Option> { + let doc = model.document.as_ref()?; + + // Only show for multi-page documents. + if !doc.is_multi_page() { + return None; + } + + let page_count = doc.page_count()?; + let loaded = doc.thumbnails_loaded(); + let current_page = doc.current_page()?; + + let mut content = column::with_capacity(page_count as usize + 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. + let thumbnail_element: Element<'static, AppMessage> = + if let Some(handle) = doc.get_thumbnail(page_index) { + cosmic_image::Image::new(handle) + .width(Length::Fixed(THUMBNAIL_MAX_WIDTH)) + .into() + } else { + // Fallback: show page number if no thumbnail. + text::body(format!("{}", page_index + 1)).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(), + ) +} diff --git a/src/app/view/panels.rs b/src/app/view/panels.rs index df5bfc8..d3aa8a6 100644 --- a/src/app/view/panels.rs +++ b/src/app/view/panels.rs @@ -17,11 +17,8 @@ pub fn properties_panel(model: &AppModel) -> Element<'static, AppMessage> { // Header with action icons content = content.push(panel_header(model)); - // Display document metadata if available. - if let Some(ref doc) = model.document { - // Use the unified interface to extract metadata. - let meta = doc.extract_meta(); - + // Display document metadata if available (cached in model). + if let Some(ref meta) = model.metadata { // --- Basic Information Section --- content = content .push(section_header(fl!("meta-section-file"))) @@ -72,7 +69,7 @@ pub fn properties_panel(model: &AppModel) -> Element<'static, AppMessage> { } if let Some(iso) = exif.iso { - content = content.push(meta_row(fl!("meta-iso"), format!("ISO {}", iso))); + content = content.push(meta_row(fl!("meta-iso"), fl!("meta-iso", iso: iso))); } if let Some(ref focal) = exif.focal_length { @@ -88,7 +85,10 @@ pub fn properties_panel(model: &AppModel) -> Element<'static, AppMessage> { // --- 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())); + .push(meta_row_small( + fl!("meta-path"), + meta.basic.file_path.clone(), + )); } else { content = content.push(text::body(fl!("no-document"))); } @@ -130,8 +130,8 @@ fn panel_header(model: &AppModel) -> Element<'static, AppMessage> { .push(horizontal_space().width(Length::Fill)) .push( button::icon(icon::from_name("image-x-generic-symbolic")) - .on_press_maybe(has_doc.then_some(AppMessage::SetAsWallpaper)) .tooltip(fl!("action-set-wallpaper")) + .on_press_maybe(has_doc.then_some(AppMessage::SetAsWallpaper)), ) // .push( // button::icon(icon::from_name("system-run-symbolic")) diff --git a/src/config.rs b/src/config.rs index eeb03d0..b9dc02b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,7 +7,7 @@ use cosmic::cosmic_config::{self, CosmicConfigEntry, cosmic_config_derive::Cosmi use std::path::PathBuf; /// Global configuration for the application. -#[derive(Debug, Clone, CosmicConfigEntry, Eq, PartialEq)] +#[derive(Debug, Clone, CosmicConfigEntry, PartialEq)] #[version = 1] pub struct AppConfig { /// Optional default directory to open images from. @@ -16,6 +16,14 @@ pub struct AppConfig { pub nav_bar_visible: bool, /// Whether the context drawer (right panel) is visible. pub context_drawer_visible: bool, + /// Scale step factor for keyboard zoom (e.g., 1.1 = 10% per step). + pub scale_step: f32, + /// Pan step size in pixels per key press. + pub pan_step: f32, + /// Minimum zoom scale (e.g., 0.1 = 10%). + pub min_scale: f32, + /// Maximum zoom scale (e.g., 20.0 = 2000%). + pub max_scale: f32, } impl Default for AppConfig { @@ -24,6 +32,10 @@ impl Default for AppConfig { 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, } } } diff --git a/src/constant.rs b/src/constant.rs new file mode 100644 index 0000000..6089716 --- /dev/null +++ b/src/constant.rs @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/constant.rs +// +// Application constants that should not be changed by the user. + +/// Rotation step in degrees (90 = quarter turn). +pub const ROTATION_STEP: i16 = 90; + +/// Full rotation in degrees (for modulo calculation in angle normalization). +pub const FULL_ROTATION: i16 = 360; + +/// Minutes per degree (GPS coordinate conversion: DMS to decimal degrees). +pub const MINUTES_PER_DEGREE: f64 = 60.0; + +/// Seconds per degree (GPS coordinate conversion: DMS to decimal degrees). +pub const SECONDS_PER_DEGREE: f64 = 3600.0; + +/// Minimum pixmap size for SVG rendering (prevents 0x0 images). +pub const MIN_PIXMAP_SIZE: u32 = 1; + +/// Tolerance for scale comparisons (float precision in zoom synchronization). +pub const SCALE_EPSILON: f32 = 0.0001; + +/// Tolerance for offset comparisons (float precision in pan synchronization). +pub const OFFSET_EPSILON: f32 = 0.01; + +/// Maximum thumbnail width in pixels (nav bar page thumbnails). +pub const THUMBNAIL_MAX_WIDTH: f32 = 100.0; + +/// Thumbnail cache directory name. +pub const CACHE_DIR: &str = "noctua"; + +/// Thumbnail file extension. +pub const THUMBNAIL_EXT: &str = "png"; + +/// Default render scale for PDF pages. +pub const PDF_RENDER_SCALE: f64 = 2.0; + +/// Thumbnail render scale (smaller for quick rendering). +pub const PDF_THUMBNAIL_SCALE: f64 = 0.25; diff --git a/src/i18n.rs b/src/i18n.rs index 153facf..d3f2b10 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -43,7 +43,11 @@ macro_rules! fl { i18n_embed_fl::fl!($crate::i18n::LANGUAGE_LOADER, $message_id) }}; - ($message_id:literal, $($args:expr),*) => {{ - i18n_embed_fl::fl!($crate::i18n::LANGUAGE_LOADER, $message_id, $($args), *) + ($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) }}; } diff --git a/src/main.rs b/src/main.rs index 65ed240..8de9d5b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod app; mod config; +mod constant; mod i18n; use anyhow::Result;